diff --git a/README.rst b/README.rst index 3dc7514..044f432 100644 --- a/README.rst +++ b/README.rst @@ -2,9 +2,45 @@ CMSPlugin Form Handler ====================== -.. important:: +This package aims to provide a mechanism for handling form-submissions in +django-CMS plugins. - This is only a Proof-of-concept, but it works! + +Background +---------- + +Plugins are a key component of `django CMS `_ for +creating reusable, configurable content fragments in django CMS projects. Due to +their flexibility and utility, project developers would benefit from emitting +forms and handling form submissions using plugins. + +Since CMS plugins are fragments of a page, they do not provide a unique, RESTful +URL for receiving and handling form submissions. This presents numerous +challenges when attempting to process form submissions. + + +Approach +-------- + +To get around these limitations, the approach taken in this package is to direct +form submissions from plugins which sub-class ``FormPluginBase`` to a URL that +is outside of the django CMS URL-space and handled by a ``ProcessFormView`` +provided by this package. + +The ``ProcessFormView`` accepts form-submissions, processes them, and if valid, +the request is redirected to a ``success_url`` provided by the plugin. On +validation errors, the view will redirect the request back to the originating +page and provide the form data via a session variable back to the plugin's form. + +The user experience is precisely as expected and the handling of the form is +performed without "thrown HTTPRedirectResponses" or special middleware. + +This package encapsulates all extra logic so that the plugin developer need +only to subclass ``cmsplugin_form_handler.cms_plugins.FormPluginBase`` rather +than the usual ``cms.plugin_base.CMSPluginBase``. + +The ``Form`` or ``ModelForm`` presented in the CMS plugin should also include +the "mixin" ``cmsplugin_form_handler.forms.FormPluginFormMixin``. ------------ @@ -55,10 +91,10 @@ inherit from PluginFormBase as follows:: from cms.models import CMSPlugin from cmsplugin_form_handler.cms_plugins import PluginFormBase - from cmsplugin_form_handler.forms import FormPluginFormBase + from cmsplugin_form_handler.forms import FormPluginFormMixin - class CoolForm(FormPluginFormBase): + class CoolForm(FormPluginFormMixin, forms.Form): # Define your form as per usual... cool_field = forms.CharField(...) @@ -74,8 +110,9 @@ inherit from PluginFormBase as follows:: As usual, you must define a ``render_template`` for your plugin. Here's one:: + {% load cmsplugin_form_tags %}

Form Plugin ({{ instance.pk }})

-
+ {% csrf_token %} {{ form }} diff --git a/cmsplugin_form_handler/__init__.py b/cmsplugin_form_handler/__init__.py index ee9e26d..d81f1ef 100644 --- a/cmsplugin_form_handler/__init__.py +++ b/cmsplugin_form_handler/__init__.py @@ -1,3 +1,9 @@ # -*- coding: utf-8 -*- -__version__ = '0.0.1' +from __future__ import unicode_literals + +__version__ = '0.1.0' + + +def get_session_key(plugin_id): + return 'cmsplugin_form_{0}'.format(plugin_id) diff --git a/cmsplugin_form_handler/cms_plugins.py b/cmsplugin_form_handler/cms_plugins.py index 8362751..74c92c7 100644 --- a/cmsplugin_form_handler/cms_plugins.py +++ b/cmsplugin_form_handler/cms_plugins.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals -from django.core.urlresolvers import reverse - from cms.plugin_base import CMSPluginBase +from . import get_session_key + class FormPluginBase(CMSPluginBase): """ @@ -19,28 +19,52 @@ class FormPluginBase(CMSPluginBase): def get_form_class(self, instance): """ - Override in subclass as required + Returns the form class to be used by this plugin. + + Default implementation is to return the contents of + FormPluginBase.form_class, but this method can be overridden as + required for more elaborate circumstances. """ return self.form_class def get_success_url(self, instance): """ - Override in subclass as required + Returns the redirect URL for successful form submissions. + + Default implementation is to return the contents of + FormPluginBase.success_url, but this method can be overridden as + required for more elaborate circumstances. """ return self.success_url + def form_valid(self, instance, form): + """ + If the form validates, this method will be called before the user is + redirected to the success_url. The default implementation is to just + save the form. + """ + form.save() + def render(self, context, instance, placeholder): - context = super(FormPluginBase, self).render(context, instance, placeholder) + context = super(FormPluginBase, self).render(context, instance, placeholder) # noqa request = context.get('request') - form_class = self.get_form_class(instance) + form_class = self.get_form_class(instance) if form_class: - context['cmsplugin_form_handler_action'] = reverse( - 'cmsplugin_form_handler:process_form', args=(instance.pk, )) - instance_id = request.GET.get('instance_id') - if instance_id and int(instance_id) == instance.pk: - data = request.GET.copy() - context['form'] = form_class(instance, data=data) + source_url = request.path + data = None + + if hasattr(request, 'session'): + data = request.session.get(get_session_key(instance.pk)) + elif request.GET.get('cmsplugin_form_plugin_id'): + # Sessions aren't available, see if we fell-back to GET params + plugin_id = request.GET.get('cmsplugin_form_plugin_id') + if plugin_id and int(plugin_id) == instance.pk: + data = request.GET.copy() + + if data: + context['cmsplugin_form'] = form_class(source_url, data=data) else: - context['form'] = form_class(instance) + request.session.set_test_cookie() + context['cmsplugin_form'] = form_class(source_url) return context diff --git a/cmsplugin_form_handler/forms.py b/cmsplugin_form_handler/forms.py index b0d1edf..0d68741 100644 --- a/cmsplugin_form_handler/forms.py +++ b/cmsplugin_form_handler/forms.py @@ -5,9 +5,7 @@ from django import forms -class FormPluginFormBase(forms.Form): - instance_id = forms.IntegerField(widget=forms.HiddenInput) - - def __init__(self, instance_id, *args, **kwargs): - super(FormPluginFormBase, self).__init__(*args, **kwargs) - self.fields['instance_id'].initial = instance_id +class FormPluginFormMixin(object): + def __init__(self, source_url, *args, **kwargs): + super(FormPluginFormMixin, self).__init__(*args, **kwargs) + self.fields['cmsplugin_form_source_url'] = forms.CharField(widget=forms.HiddenInput, initial=source_url) diff --git a/cmsplugin_form_handler/templatetags/__init__.py b/cmsplugin_form_handler/templatetags/__init__.py new file mode 100644 index 0000000..685e220 --- /dev/null +++ b/cmsplugin_form_handler/templatetags/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + diff --git a/cmsplugin_form_handler/templatetags/cmsplugin_form_tags.py b/cmsplugin_form_handler/templatetags/cmsplugin_form_tags.py new file mode 100644 index 0000000..9b86721 --- /dev/null +++ b/cmsplugin_form_handler/templatetags/cmsplugin_form_tags.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from django import template +from django.core.urlresolvers import reverse + +from classytags.arguments import Argument +from classytags.core import Options +from classytags.helpers import AsTag + +register = template.Library() + + +class FormAction(AsTag): + name = 'cmsplugin_form_action' + options = Options( + Argument('plugin_id', required=False, resolve=False), + 'as', + Argument('varname', required=False, resolve=False), + ) + + def get_value(self, context, **kwargs): + """ + If no «plugin_id» is provided, then set plugin_id to `instance.pk` + where `instance` comes from the context and is the plugin instance + which is conventionally added to the context in CMSPluginBase.render(). + """ + plugin_id = kwargs.get('plugin_id') + if not plugin_id and 'instance' in context: + plugin_id = context.get('instance').pk + return reverse( + 'cmsplugin_form_handler:process_form', + args=(plugin_id, ) + ) + +register.tag(FormAction) diff --git a/cmsplugin_form_handler/urls.py b/cmsplugin_form_handler/urls.py index eced2bb..8d75ef3 100644 --- a/cmsplugin_form_handler/urls.py +++ b/cmsplugin_form_handler/urls.py @@ -9,5 +9,5 @@ app_name = 'plugin_form_handler' urlpatterns = [ - url(r'^(?P\d+)/$', ProcessFormView.as_view(), name='process_form'), + url(r'^(?P\d+)/$', ProcessFormView.as_view(), name='process_form'), ] diff --git a/cmsplugin_form_handler/views.py b/cmsplugin_form_handler/views.py index 2e84467..b17c7a7 100644 --- a/cmsplugin_form_handler/views.py +++ b/cmsplugin_form_handler/views.py @@ -2,13 +2,21 @@ from __future__ import unicode_literals -import urllib.parse +try: + from urllib.parse import urlparse, urlencode # py3 +except ImportError: + from urlparse import urlparse # py2 + from urllib import urlencode # py2 -from django.shortcuts import Http404, redirect +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import redirect +from django.utils.functional import cached_property from django.views.generic import FormView from cms.models import CMSPlugin +from . import get_session_key + class ProcessFormView(FormView): """ @@ -19,50 +27,94 @@ class ProcessFormView(FormView): If the form is not valid, then send it back whence it came. """ - http_method_names = ['post', ] + http_method_names = ['post', 'put', ] - def get_plugin(self): + @cached_property + def plugin(self): """ Returns (instance, plugin) for the source plugin if found, else 404. """ - if self.kwargs['instance_id']: - cms_plugin_instance = CMSPlugin.objects.get(pk=self.kwargs['instance_id']) - return cms_plugin_instance.get_plugin_instance() - raise Http404('Source plugin not found.') + try: + plugin_id = int(self.kwargs.get('plugin_id')) + cms_plugin_instance = CMSPlugin.objects.get(pk=plugin_id) + except (KeyError, TypeError, CMSPlugin.DoesNotExist) as e: + raise ImproperlyConfigured('Source form plugin not found.') + return cms_plugin_instance.get_plugin_instance() + + @cached_property + def source_url(self): + source_url = self.request.POST.get('cmsplugin_form_source_url') + return source_url def get_form_class(self): - instance, plugin = self.get_plugin() + instance, plugin = self.plugin if hasattr(plugin, 'get_form_class'): return plugin.get_form_class(instance) - else: - raise Http404('Source plugin does not define `get_form_class()`.') + raise ImproperlyConfigured( + 'Source form plugin does not define `get_form_class()`.') def get_form_kwargs(self): kwargs = super(ProcessFormView, self).get_form_kwargs() - kwargs['instance_id'] = self.kwargs['instance_id'] + kwargs['source_url'] = self.source_url return kwargs - def get_source_url(self): - url_obj = urllib.parse.urlparse(self.request.META.get('HTTP_REFERER')) - return url_obj.path - def get_success_url(self): - instance, plugin = self.get_plugin() - if hasattr(plugin, 'get_success_url'): + instance, plugin = self.plugin + try: url = plugin.get_success_url(instance) return url - raise Http404('Source plugin does not define `get_success_url()`.') + except AttributeError: + raise ImproperlyConfigured( + 'Source plugin does not define `get_success_url()`.') + + def get_form_valid(self): + """ + Returns the `form_valid()` callback as a bound method from the + source plugin. + """ + instance, plugin = self.plugin + try: + callback = plugin.on_valid_form(instance) + return callback + except AttributeError: + return None + + def form_valid(self, form): + """ + Send the validated form back to the plugin for handling before + redirecting to the `success_url`. + """ + # Clean up our session var as it is no longer relevant. + if hasattr(self.request, 'session'): + session_key = get_session_key(self.plugin[0].pk) + if session_key in self.request.session: + del self.request.session[session_key] + + # If the source plugin has declared a `form_valid` method, call it with + # the validated form before redirecting to the `success_url`. + instance, plugin = self.plugin + plugin.form_valid(instance, form) + + return super(ProcessFormView, self).form_valid(form) def form_invalid(self, form): """ Return to sender """ - # Probably don't need this on the URL + plugin_id = self.plugin[0].pk + url = self.source_url data = form.data.copy() - del(data['csrfmiddlewaretoken']) + if getattr(self.request, 'session'): + session_key = get_session_key(plugin_id) + self.request.session[session_key] = data + else: + # Fallback to GET params... + # Don't need this on the URL + del data['csrfmiddlewaretoken'] + # We will need this though. + data['cmsplugin_form_plugin_id'] = plugin_id - url = '{0}?{1}'.format( - self.get_source_url(), - urllib.parse.urlencode(data), - ) + params = urlparse(url) + params.update(data) + url = '{0}?{1}'.format(self.source_url, params) return redirect(url)