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 @@