summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimon Charette <charette.s@gmail.com>2013-11-17 17:26:20 -0500
committerTim Graham <timograham@gmail.com>2015-01-10 12:24:52 -0500
commit07988744b347302925bc6cc66511e34224db55ab (patch)
tree8931006956c957f377133fda6852dafc60e03ff9
parent48ad288679a0cb2e2cfb17f128903e6c5b1c4870 (diff)
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.
-rw-r--r--django/contrib/admin/options.py68
-rw-r--r--django/contrib/admin/static/admin/css/widgets.css10
-rw-r--r--django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js45
-rw-r--r--django/contrib/admin/static/admin/js/related-widget-wrapper.js23
-rw-r--r--django/contrib/admin/templates/admin/delete_confirmation.html2
-rw-r--r--django/contrib/admin/templates/admin/popup_response.html8
-rw-r--r--django/contrib/admin/templates/admin/related_widget_wrapper.html30
-rw-r--r--django/contrib/admin/widgets.py64
-rw-r--r--docs/ref/contrib/admin/index.txt9
-rw-r--r--docs/releases/1.8.txt25
-rw-r--r--tests/admin_custom_urls/tests.py2
-rw-r--r--tests/admin_views/admin.py3
-rw-r--r--tests/admin_views/models.py3
-rw-r--r--tests/admin_views/tests.py130
-rw-r--r--tests/admin_widgets/models.py3
-rw-r--r--tests/admin_widgets/tests.py46
-rw-r--r--tests/modeladmin/tests.py26
17 files changed, 427 insertions, 70 deletions
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 3e682f9b10..39c27c1b25 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -188,11 +188,16 @@ class BaseModelAdmin(six.with_metaclass(forms.MediaDefiningClass)):
# OneToOneField with parent_link=True or a M2M intermediary.
if formfield and db_field.name not in self.raw_id_fields:
related_modeladmin = self.admin_site._registry.get(db_field.rel.to)
- can_add_related = bool(related_modeladmin and
- related_modeladmin.has_add_permission(request))
+ wrapper_kwargs = {}
+ 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, db_field.rel, self.admin_site,
- can_add_related=can_add_related)
+ formfield.widget, db_field.rel, self.admin_site, **wrapper_kwargs
+ )
return formfield
@@ -703,17 +708,18 @@ class ModelAdmin(BaseModelAdmin):
from django.contrib.admin.views.main import 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
- returned if no match is found (or the object_id failed validation
- against the primary key field).
+ Returns an instance matching the field and value provided, the primary
+ key is used if no field is provided. Returns ``None`` if no match is
+ found or the object_id fails validation.
"""
queryset = self.get_queryset(request)
model = queryset.model
+ field = model._meta.pk if from_field is None else model._meta.get_field(from_field)
try:
- object_id = model._meta.pk.to_python(object_id)
- return queryset.get(pk=object_id)
+ object_id = field.to_python(object_id)
+ return queryset.get(**{field.name: object_id})
except (model.DoesNotExist, ValidationError, ValueError):
return None
@@ -1186,6 +1192,19 @@ class ModelAdmin(BaseModelAdmin):
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
pk_value = obj._get_pk_val()
preserved_filters = self.get_preserved_filters(request)
@@ -1324,17 +1343,23 @@ class ModelAdmin(BaseModelAdmin):
self.message_user(request, msg, messages.WARNING)
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.
"""
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,
_('The %(name)s "%(obj)s" was deleted successfully.') % {
'name': force_text(opts.verbose_name),
- 'obj': force_text(obj_display)
+ 'obj': force_text(obj_display),
}, messages.SUCCESS)
if self.has_change_permission(request, None):
@@ -1355,6 +1380,10 @@ class ModelAdmin(BaseModelAdmin):
app_label = opts.app_label
request.current_app = self.admin_site.name
+ context.update(
+ to_field_var=TO_FIELD_VAR,
+ is_popup_var=IS_POPUP_VAR,
+ )
return TemplateResponse(request,
self.delete_confirmation_template or [
@@ -1409,7 +1438,7 @@ class ModelAdmin(BaseModelAdmin):
obj = None
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):
raise PermissionDenied
@@ -1654,7 +1683,11 @@ class ModelAdmin(BaseModelAdmin):
opts = self.model._meta
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):
raise PermissionDenied
@@ -1676,10 +1709,12 @@ class ModelAdmin(BaseModelAdmin):
if perms_needed:
raise PermissionDenied
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.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)
@@ -1700,6 +1735,9 @@ class ModelAdmin(BaseModelAdmin):
opts=opts,
app_label=app_label,
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 {})
diff --git a/django/contrib/admin/static/admin/css/widgets.css b/django/contrib/admin/static/admin/css/widgets.css
index 56817228f3..1a7ccc1e25 100644
--- a/django/contrib/admin/static/admin/css/widgets.css
+++ b/django/contrib/admin/static/admin/css/widgets.css
@@ -576,3 +576,13 @@ ul.orderer li.deleted:hover, ul.orderer li.deleted a.selector:hover {
font-size: 11px;
border-top: 1px solid #ddd;
}
+
+/* RELATED WIDGET WRAPPER */
+
+.related-widget-wrapper-link {
+ opacity: 0.3;
+}
+
+.related-widget-wrapper-link:link {
+ opacity: 1;
+}
diff --git a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js
index 01580fd833..ffba7cdf24 100644
--- a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js
+++ b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js
@@ -56,11 +56,16 @@ function dismissRelatedLookupPopup(win, chosenId) {
win.close();
}
-function showAddAnotherPopup(triggeringLink) {
- return showAdminPopup(triggeringLink, /^add_/);
+function showRelatedObjectPopup(triggeringLink) {
+ 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
// django.utils.html.escape.
newId = html_unescape(newId);
@@ -81,6 +86,8 @@ function dismissAddAnotherPopup(win, newId, newRepr) {
elem.value = newId;
}
}
+ // Trigger a change event to update related links if required.
+ django.jQuery(elem).trigger('change');
} else {
var toId = name + "_to";
o = new Option(newRepr, newId);
@@ -89,3 +96,35 @@ function dismissAddAnotherPopup(win, newId, newRepr) {
}
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;
diff --git a/django/contrib/admin/static/admin/js/related-widget-wrapper.js b/django/contrib/admin/static/admin/js/related-widget-wrapper.js
new file mode 100644
index 0000000000..dbb1f58c8c
--- /dev/null
+++ b/django/contrib/admin/static/admin/js/related-widget-wrapper.js
@@ -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();
+ });
+});
diff --git a/django/contrib/admin/templates/admin/delete_confirmation.html b/django/contrib/admin/templates/admin/delete_confirmation.html
index b2900ff4f1..cc83be8289 100644
--- a/django/contrib/admin/templates/admin/delete_confirmation.html
+++ b/django/contrib/admin/templates/admin/delete_confirmation.html
@@ -36,6 +36,8 @@
<form action="" method="post">{% csrf_token %}
<div>
<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" %}" />
<a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a>
</div>
diff --git a/django/contrib/admin/templates/admin/popup_response.html b/django/contrib/admin/templates/admin/popup_response.html
index 281f0354ee..3f8de11102 100644
--- a/django/contrib/admin/templates/admin/popup_response.html
+++ b/django/contrib/admin/templates/admin/popup_response.html
@@ -3,7 +3,13 @@
<head><title></title></head>
<body>
<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>
</body>
</html>
diff --git a/django/contrib/admin/templates/admin/related_widget_wrapper.html b/django/contrib/admin/templates/admin/related_widget_wrapper.html
new file mode 100644
index 0000000000..51c56e723e
--- /dev/null
+++ b/django/contrib/admin/templates/admin/related_widget_wrapper.html
@@ -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>
diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py
index c7312b9a46..10ac364f74 100644
--- a/django/contrib/admin/widgets.py
+++ b/django/contrib/admin/widgets.py
@@ -8,8 +8,10 @@ import copy
from django import forms
from django.contrib.admin.templatetags.admin_static import static
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.template.loader import render_to_string
from django.utils.html import escape, format_html, format_html_join, smart_urlquote
from django.utils.text import Truncator
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
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.attrs = widget.attrs
self.choices = widget.choices
@@ -243,6 +248,12 @@ class RelatedFieldWidgetWrapper(forms.Widget):
if can_add_related is None:
can_add_related = rel.to in admin_site._registry
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
self.admin_site = admin_site
@@ -259,22 +270,47 @@ class RelatedFieldWidgetWrapper(forms.Widget):
@property
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):
- 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
- 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:
- rel_to = self.rel.to
- info = (rel_to._meta.app_label, rel_to._meta.model_name)
- related_url = reverse('admin:%s_%s_add' % info, current_app=self.admin_site.name)
- url_params = '?%s=%s' % (TO_FIELD_VAR, self.rel.get_related_field().name)
- # TODO: "add_id_" is hard-coded here. This should instead use the
- # correct API to determine the ID dynamically.
- output.append('<a href="%s%s" class="add-another" id="add_id_%s" title="%s"></a>'
- % (related_url, url_params, name, _('Add Another')))
- return mark_safe(''.join(output))
+ add_related_url = self.get_related_url(info, 'add')
+ context.update(
+ can_add_related=True,
+ add_related_url=add_related_url,
+ )
+ if self.can_delete_related:
+ delete_related_template_url = self.get_related_url(info, 'delete', '__fk__')
+ context.update(
+ 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):
"Helper function for building an attribute dictionary."
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index a19f9fe7be..490dd5b49d 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -1736,7 +1736,7 @@ templates used by the :class:`ModelAdmin` views:
been saved. You can override it to change the default
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
@@ -1750,6 +1750,13 @@ templates used by the :class:`ModelAdmin` views:
``obj_display`` is a string with the name of the deleted
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)
.. versionadded:: 1.7
diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt
index 5372f0ae99..7b01fbf9fe 100644
--- a/docs/releases/1.8.txt
+++ b/docs/releases/1.8.txt
@@ -161,6 +161,9 @@ Minor features
its value from :meth:`~django.contrib.admin.AdminSite.has_permission`,
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`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -913,6 +916,23 @@ those writing third-party backends in updating their code:
``data_type_check_constraints`` attributes have moved from the
``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
~~~~~~~~~~~~~
@@ -945,11 +965,6 @@ Miscellaneous
``'username'``, using the the ``'unique'`` key in its
: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
:mod:`django.contrib.admin` now requires the ``has_permission`` context
variable to be set. If you have any custom admin views that use this
diff --git a/tests/admin_custom_urls/tests.py b/tests/admin_custom_urls/tests.py
index d327b33407..f3182cd246 100644
--- a/tests/admin_custom_urls/tests.py
+++ b/tests/admin_custom_urls/tests.py
@@ -50,7 +50,7 @@ class AdminCustomUrlsTest(TestCase):
}
response = self.client.post('/admin/admin_custom_urls/action/!add/', post_data)
self.assertEqual(response.status_code, 200)
- self.assertContains(response, 'dismissAddAnotherPopup')
+ self.assertContains(response, 'dismissAddRelatedObjectPopup')
self.assertContains(response, 'Action added through a popup')
def test_admin_URLs_no_clash(self):
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index b81de1d4ac..c9f0dbc2e3 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -53,6 +53,7 @@ callable_year.admin_order_field = 'date'
class ArticleInline(admin.TabularInline):
model = Article
+ fk_name = 'section'
prepopulated_fields = {
'title': ('content',)
}
@@ -93,7 +94,7 @@ class ArticleAdmin(admin.ModelAdmin):
}),
('Some other fields', {
'classes': ('wide',),
- 'fields': ('date', 'section')
+ 'fields': ('date', 'section', 'sub_section')
})
)
diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py
index 6c12030341..8a78514cec 100644
--- a/tests/admin_views/models.py
+++ b/tests/admin_views/models.py
@@ -32,6 +32,7 @@ class Article(models.Model):
content = models.TextField()
date = models.DateTimeField()
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):
return self.title
@@ -545,7 +546,7 @@ class Pizza(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)
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 07baec7163..04dfb096a5 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -180,7 +180,7 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
}
response = self.client.post('/test_admin/%s/admin_views/article/add/' % self.urlbit, post_data)
self.assertEqual(response.status_code, 200)
- self.assertContains(response, 'dismissAddAnotherPopup')
+ self.assertContains(response, 'dismissAddRelatedObjectPopup')
self.assertContains(response, 'title with a new\\u000Aline')
# 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'})
self.assertEqual(response.status_code, 200)
- # We also want to prevent the add and change view from leaking a
- # disallowed field value.
+ # We also want to prevent the add, change, and delete views from
+ # leaking a disallowed field value.
with patch_logger('django.security.DisallowedModelAdminToField', 'error') as calls:
response = self.client.post("/test_admin/admin/admin_views/section/add/", {TO_FIELD_VAR: 'name'})
self.assertEqual(response.status_code, 400)
@@ -661,6 +661,11 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
self.assertEqual(response.status_code, 400)
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):
"""
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/'
# Set up and log in user.
url = '/test_admin/admin/admin_views/article/add/'
- add_link_text = ' class="add-another"'
- self.client.get('/test_admin/admin/')
+ add_link_text = 'add_id_section'
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.
response = self.client.get(url)
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.
- add_user = User.objects.get(username='adduser')
+ user = User.objects.get(username='adduser')
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)
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):
login_url = reverse('admin:login') + '?next=/test_admin/admin/'
self.client.get('/test_admin/admin/')
@@ -4140,12 +4199,12 @@ class UserAdminTest(TestCase):
self.assertEqual(adminform.form.errors['password2'],
["The two password fields didn't match."])
- def test_user_fk_popup(self):
- """Quick user addition in a FK popup shouldn't invoke view for further user customization"""
+ def test_user_fk_add_popup(self):
+ """User addition 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/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')
self.assertEqual(response.status_code, 200)
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)
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):
user_count = User.objects.count()
diff --git a/tests/admin_widgets/models.py b/tests/admin_widgets/models.py
index 97153caadc..5d65dcd174 100644
--- a/tests/admin_widgets/models.py
+++ b/tests/admin_widgets/models.py
@@ -108,7 +108,8 @@ class Individual(models.Model):
related instances (rendering will be called programmatically in this case).
"""
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):
diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py
index 3180dbcb9c..c5795c87fb 100644
--- a/tests/admin_widgets/tests.py
+++ b/tests/admin_widgets/tests.py
@@ -510,6 +510,32 @@ class RelatedFieldWidgetWrapperTests(DjangoTestCase):
w = widgets.RelatedFieldWidgetWrapper(w, rel, widget_admin_site)
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',),
ROOT_URLCONF='admin_widgets.urls')
@@ -1134,9 +1160,27 @@ class RelatedFieldWidgetSeleniumFirefoxTests(AdminSeleniumWebDriverTestCase):
# The field now contains the new user
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
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()
self.assertEqual(len(profiles), 1)
self.assertEqual(profiles[0].user.username, username_value)
diff --git a/tests/modeladmin/tests.py b/tests/modeladmin/tests.py
index ca3a8dbfaf..ee8c26c3ca 100644
--- a/tests/modeladmin/tests.py
+++ b/tests/modeladmin/tests.py
@@ -355,30 +355,30 @@ class ModelAdminTests(TestCase):
form = ma.get_form(request)()
self.assertHTMLEqual(str(form["main_band"]),
- '<select name="main_band" id="id_main_band">\n'
- '<option value="" selected="selected">---------</option>\n'
- '<option value="%d">The Beatles</option>\n'
- '<option value="%d">The Doors</option>\n'
- '</select>' % (band2.id, self.band.id))
+ '<div class="related-widget-wrapper">'
+ '<select name="main_band" id="id_main_band">'
+ '<option value="" selected="selected">---------</option>'
+ '<option value="%d">The Beatles</option>'
+ '<option value="%d">The Doors</option>'
+ '</select></div>' % (band2.id, self.band.id))
class AdminConcertForm(forms.ModelForm):
- pass
-
def __init__(self, *args, **kwargs):
super(AdminConcertForm, self).__init__(*args, **kwargs)
self.fields["main_band"].queryset = Band.objects.filter(name='The Doors')
- class ConcertAdmin(ModelAdmin):
+ class ConcertAdminWithForm(ModelAdmin):
form = AdminConcertForm
- ma = ConcertAdmin(Concert, self.site)
+ ma = ConcertAdminWithForm(Concert, self.site)
form = ma.get_form(request)()
self.assertHTMLEqual(str(form["main_band"]),
- '<select name="main_band" id="id_main_band">\n'
- '<option value="" selected="selected">---------</option>\n'
- '<option value="%d">The Doors</option>\n'
- '</select>' % self.band.id)
+ '<div class="related-widget-wrapper">'
+ '<select name="main_band" id="id_main_band">'
+ '<option value="" selected="selected">---------</option>'
+ '<option value="%d">The Doors</option>'
+ '</select></div>' % self.band.id)
def test_regression_for_ticket_15820(self):
"""