Source code for adminlinks.admin

# -*- coding: utf-8 -*-
from itertools import chain
import logging
from django.utils.html import escape

try:
    from django.utils.six.moves import urllib_parse
    urlsplit = urllib_parse.urlsplit
    urlunsplit = urllib_parse.urlunsplit
except (ImportError, AttributeError) as e:  # Python 2, < Django 1.5
    from urlparse import urlsplit, urlunsplit
from django.contrib.admin import helpers
from django.contrib.admin.options import csrf_protect_m
try:
    from django.contrib.admin.utils import unquote
except ImportError:  # < 1.7 ... pragma: no cover
    from django.contrib.admin.util import unquote
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.core.exceptions import PermissionDenied
from django.db import transaction
try:
    from django.db.transaction import atomic
except ImportError:
    from django.db.transaction import commit_on_success as atomic
from django.forms.models import fields_for_model
from django.http import Http404, QueryDict
from django.shortcuts import render_to_response
try:
    from django.utils.encoding import force_text
except ImportError:  # < Django 1.5
    from django.utils.encoding import force_unicode as force_text
try:
    # >=1.6
    import json
    from functools import update_wrapper
except ImportError as e:
    # <=1.5
    from django.utils import simplejson as json
    from django.utils.functional import update_wrapper

from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from adminlinks.changelist import AdminlinksChangeList
from adminlinks.constants import DATA_CHANGED, AUTOCLOSING


logger = logging.getLogger(__name__)


[docs]class AdminUrlWrap(object): """ A minor helper for mixing into :class:`~django.contrib.admin.ModelAdmin` instances, exposing the url wrapping function used in the standard :meth:`~django.contrib.admin.ModelAdmin.get_urls`. """
[docs] def _get_wrap(self): """ Returns some magical decorated view that applies :meth:`~django.contrib.admin.AdminSite.admin_view` to a :class:`~django.contrib.admin.ModelAdmin` view:: from django.contrib.admin import ModelAdmin class MyObj(AdminUrlWrap, ModelAdmin): def do_something(self): wrapper = self._get_wrap() wrapped_view = wrapper(self.my_custom_view) return wrapped_view :return: a decorated view. """ assert hasattr(self, 'admin_site') is True, "No AdminSite found ..." def wrap(view): def wrapper(*args, **kwargs): return self.admin_site.admin_view(view)(*args, **kwargs) return update_wrapper(wrapper, view) return wrap
[docs]class AdminlinksMixin(AdminUrlWrap): """ Our mixin, which serves two purposes: * Allows for per-field editing through the use of the use of :meth:`~adminlinks.admin.AdminlinksMixin.change_field_view`. * Per-field editing requires the *change* permission for the object. * Per-field editing is not exposed in the :class:`~django.contrib.admin.AdminSite` at all. * Per-field editing may be enabled in the frontend by using the :class:`~adminlinks.templatetags.adminlinks_buttons.EditField` template tag * Allows for the following views to be automatically closed on success, using a customisable template (see :meth:`~adminlinks.admin.AdminlinksMixin.get_success_templates` for how template discovery works.) * The add view (:meth:`~django.contrib.admin.ModelAdmin.add_view`) * The edit view (:meth:`~django.contrib.admin.ModelAdmin.change_view`) * The delete view (:meth:`~django.contrib.admin.ModelAdmin.delete_view`) * The edit-field view ( :meth:`~adminlinks.admin.AdminlinksMixin.change_field_view`) """ @csrf_protect_m @atomic
[docs] def change_field_view(self, request, object_id, fieldname, extra_context=None): """ Allows a user to view a form with only one field (named in the URL args) to edit. All others are ignored. """ model = self.model opts = model._meta obj = self.get_object(request, unquote(object_id)) if not self.has_change_permission(request, obj): raise PermissionDenied if obj is None: raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_text(opts.verbose_name), 'key': escape(object_id)}) all_fields = set(chain(fields_for_model(obj), self.form.base_fields)) if fieldname not in all_fields: raise Http404(_('%(field)s does not exist on this object') % {'field': force_text(fieldname)}) # if there are important fields, we'll assume they're denoted by a # leading underscore, like in treebeard fields_to_include = [x for x in all_fields if x == fieldname or x.startswith('_')] fields_to_exclude = [x for x in all_fields if x != fieldname and not x.startswith('_')] unique_together = frozenset(obj._meta.unique_together) if len(unique_together) > 0 and fieldname in unique_together: fields_to_include.extend(unique_together) fields_to_exclude = [x for x in fields_to_exclude if x not in unique_together] logger.debug("Excluding fields: {0!r}".format(fields_to_exclude)) logger.debug("Including fields: {0!r}".format(fields_to_include)) ModelForm = self.get_form(request, obj, exclude=fields_to_exclude, fields=fields_to_include) if request.method == 'POST': form = ModelForm(request.POST, request.FILES, instance=obj) if form.is_valid(): form_validated = True new_object = self.save_form(request, form, change=True) else: logger.debug('{cls!r} instance was invalid: {errors!r}'.format( errors=form.errors, cls=form)) form_validated = False new_object = obj if form_validated: self.save_model(request, new_object, form, change=True) form.save_m2m() change_message = self.construct_change_message(request, form, formsets=None) self.log_change(request, new_object, change_message) return self.response_change(request, new_object) else: form = ModelForm(instance=obj) the_fieldset = [ (None, { 'fields': [fieldname]}), ] remaining_fields = fields_to_include[:] remaining_fields.remove(fieldname) if remaining_fields: the_fieldset.append( (_("Other"), { 'fields': remaining_fields, 'classes': ['collapse']}) ) adminForm = helpers.AdminForm(form, the_fieldset, prepopulated_fields={}, readonly_fields=None, model_admin=self) media = self.media + adminForm.media context = { 'title': '', 'adminform': adminForm, 'object_id': object_id, 'original': obj, 'show_delete': False, 'media': mark_safe(media), 'errors': helpers.AdminErrorList(form, inline_formsets=[]), 'root_path': getattr(self.admin_site, 'root_path', None), 'app_label': opts.app_label, 'is_popup': "_popup" in request.REQUEST, } context.update(extra_context or {}) # tidy up after ourselves; by this point we don't need anything much ... # print(', '.join(locals().keys())) del (all_fields, adminForm, form, ModelForm, media, object_id, fields_to_exclude, fieldname, model, extra_context, the_fieldset, opts) return self.render_change_form(request, context, obj=obj)
def get_urls(self): urls = super(AdminlinksMixin, self).get_urls() from django.conf.urls import url opts = self.model._meta app_label = opts.app_label # avoid deprecation warning if hasattr(opts, 'model_name'): model_name = opts.model_name else: model_name = opts.module_name # add change_field view into our URLConf new_url = url( regex=r'^(?P<object_id>.+)/change_field/(?P<fieldname>[\w_]+)/$', view=self._get_wrap()(self.change_field_view), name='{app}_{model}_change_field'.format(app=app_label, model=model_name)) urls.insert(0, new_url) return urls
[docs] def get_success_templates(self, request): """ Forces the attempted loading of the following: - a template for this model. - a template for this app. - a template for any parent model. - a template for any parent app. - a guaranteed to exist template (the base success file) :param request: The WSGIRequest :return: list of strings representing templates to look for. """ app_label = self.model._meta.app_label model_name = self.model._meta.object_name.lower() any_parents = self.model._meta.parents.keys() templates = [ "adminlinks/%s/%s/success.html" % (app_label, model_name), "adminlinks/%s/success.html" % app_label, ] for parent in any_parents: app_label = parent._meta.app_label model_name = parent._meta.object_name.lower() templates.extend([ "adminlinks/%s/%s/ssuccess.html" % (app_label, model_name), "adminlinks/%s/success.html" % app_label, ]) templates.extend(['adminlinks/success.html']) del app_label, model_name, any_parents return templates
[docs] def wants_to_autoclose(self, request): """ .. versionadded:: 0.8.1 :return: Whether or not ``_autoclose`` was in the request """ return AUTOCLOSING in request.GET or AUTOCLOSING in request.POST
[docs] def wants_to_continue_editing(self, request): """ .. versionadded:: 0.8.1 :return: Whether **Save** was pressed, or whether **Save and add another/continue editing** was. """ return any(( '_continue' in request.POST, '_saveasnew' in request.POST, '_addanother' in request.POST ))
[docs] def data_changed(self, querydict): """ Can be passed things like request.GET, or just dictionaries, whatever. This is our magic querystring variable. .. versionadded:: 0.8.1 """ return DATA_CHANGED in querydict
[docs] def should_autoclose(self, request): """ .. versionadded:: 0.8.1 :return: Whether or not ``_autoclose`` was in the request and whether **Save** was pressed, or whether **Save and add another/continue editing** was. """ if self.wants_to_continue_editing(request): return False if self.wants_to_autoclose(request): return True return False
[docs] def maybe_fix_redirection(self, request, response, obj=None): """ This is a middleware-ish thing for marking whether a redirect needs to say data changed ... it's pretty complex, so has lots of comments. .. versionadded:: 0.8.1 """ # if there's no Location string, it's not even a redirect! if not response.has_header('location'): return response response.redirect_parts = list(urlsplit(response['Location'])) querystring = QueryDict(response.redirect_parts[3], mutable=True) # if we got this far, we know: # * it's a redirect (it has a Location header) # * because it's a redirect, something has changed (been added/edited) # * it may want to autoclose, but can't (because add another/continue # editing was selected) # if the redirect doesn't already know that data has been changed, # fix that here. if not self.data_changed(querystring): # if the redirect: # * is to a changelist view # * the changelist view does not subclass AdminlinksChangeListMixin # then this will cause another redirect to e=1 which may lose # any other querystring parts. Need to look into a fix for this. # Possibly we can resolve() the response.redirect_parts[2] # and figure it out using tracks_querystring_keys? querystring.update({DATA_CHANGED: 1}) # the view wanted to autoclose, but couldn't because # `wants_to_continue_editing` was True, so keep track of the desire # to autoclose, and maybe next time 'save' is hit it'll still be around # to do so. if self.wants_to_autoclose(request) and AUTOCLOSING not in querystring: querystring.update({AUTOCLOSING: 1}) # should there be a `next` parameter, we'll treat it as canonical # override for any other action. if REDIRECT_FIELD_NAME in request.GET: next_url = request.GET[REDIRECT_FIELD_NAME] if (self.wants_to_continue_editing(request) and REDIRECT_FIELD_NAME not in querystring): # save & add another, or save & continue editing was clicked # so we just presist the redirection location ... querystring.update({REDIRECT_FIELD_NAME: next_url}) else: # `save` was pressed redir = unquote(next_url) if redir.startswith('/') and not redir.startswith('//'): # patch the existing redirect with the *FINAL* data. # also maintaining any querystring changes. new_parts = list(urlsplit(redir)) # change path if new_parts[2]: response.redirect_parts[2] = new_parts[2] response.canonical = True # include querystring. if new_parts[3]: querystring.update(QueryDict(new_parts[3], mutable=False)) response.redirect_parts[3] = querystring.urlencode() response['Location'] = urlunsplit(response.redirect_parts) return response
[docs] def response_change(self, request, obj, *args, **kwargs): """ Overrides the Django default, to try and provide a better experience for frontend editing when editing an existing object. """ if self.should_autoclose(request): ctx_dict = self.get_response_change_context(request, obj) ctx_json = json.dumps(ctx_dict) context = {'data': ctx_dict, 'json': ctx_json} return render_to_response(self.get_success_templates(request), context) response = super(AdminlinksMixin, self).response_change(request, obj, *args, **kwargs) return self.maybe_fix_redirection(request, response, obj)
[docs] def response_add(self, request, obj, *args, **kwargs): """ Overrides the Django default, to try and provide a better experience for frontend editing when adding a new object. """ if self.should_autoclose(request): ctx_dict = self.get_response_add_context(request, obj) ctx_json = json.dumps(ctx_dict) context = {'data': ctx_dict, 'json': ctx_json} return render_to_response(self.get_success_templates(request), context) response = super(AdminlinksMixin, self).response_add(request, obj, *args, **kwargs) return self.maybe_fix_redirection(request, response, obj)
[docs] def delete_view(self, request, object_id, extra_context=None): """ Overrides the Django default, to try and provide a better experience for frontend editing when deleting an object successfully. Ridiculously, there's no response_delete method to patch, so instead we're just going to do a similar thing and hope for the best. """ response = super(AdminlinksMixin, self).delete_view(request, object_id, extra_context) if self.should_autoclose(request) and response.status_code in (301, 302): ctx_dict = self.get_response_delete_context(request, object_id, extra_context) ctx_json = json.dumps(ctx_dict) context = {'data': ctx_dict, 'json': ctx_json} response = render_to_response(self.get_success_templates(request), context) del context, ctx_dict, ctx_json return self.maybe_fix_redirection(request, response)
[docs] def get_response_add_context(self, request, obj): """ Provides a context for the template discovered by :meth:`~adminlinks.admin.AdminlinksMixin.get_success_templates`. Only used when we could reliably determine that the request was in our JavaScript modal window, allowing us to close it automatically. For clarity's sake, it should always return the minimum values represented here. :return: Data which may be given to a template. Must be JSON serializable, so that a template may pass it back to the browser's JavaScript engine. :rtype: a dictionary. """ return { 'action': { 'add': True, 'change': False, 'delete': False, }, 'object': { 'pk': obj._get_pk_val(), 'id': obj._get_pk_val(), } }
[docs] def get_response_change_context(self, request, obj): """ Provides a context for the template discovered by :meth:`~adminlinks.admin.AdminlinksMixin.get_success_templates`. Only used when we could reliably determine that the request was in our JavaScript modal window, allowing us to close it automatically. For clarity's sake, it should always return the minimum values represented here. :return: Data which may be given to a template. Must be JSON serializable, so that a template may pass it back to the browser's JavaScript engine. :rtype: a dictionary. """ return { 'action': { 'add': False, 'change': True, 'delete': False, }, 'object': { 'pk': obj._get_pk_val(), 'id': obj._get_pk_val(), } }
[docs] def get_response_delete_context(self, request, obj_id, extra_context): """ Provides a context for the template discovered by :meth:`~adminlinks.admin.AdminlinksMixin.get_success_templates`. Only used when we could reliably determine that the request was in our JavaScript modal window, allowing us to close it automatically. For clarity's sake, it should always return the minimum values represented here. .. note:: At the point this is called, the original object no longer exists, so we are stuck trusting the `obj_id` given as an argument. .. versionchanged:: Introduced ``extra_context`` parameter. :return: Data which may be given to a template. Must be JSON serializable, so that a template may pass it back to the browser's JavaScript engine. :rtype: a dictionary. """ return { 'action': { 'add': False, 'change': False, 'delete': True, }, 'object': { 'pk': obj_id, 'id': obj_id, } }
[docs] def get_changelist(self, request, **kwargs): """ If the changelist hasn't been customised, lets just replace it with our own, which should allow us to track data changes without erroring. .. versionadded:: 0.8.1 """ cl = super(AdminlinksMixin, self).get_changelist(request, **kwargs) fits_requirements = ( hasattr(cl, 'tracks_querystring_keys'), DATA_CHANGED in getattr(cl, 'tracks_querystring_keys', ()) ) if all(fits_requirements): return cl else: logger.warning('Custom `ChangeList` discovered,' 'AdminlinksChangeListMixin is being mixed in ' 'automatically. Hopefully it will work!') cl = type('AutoPatchedChangeList', (AdminlinksChangeList, cl), {}) return cl