diff --git a/src/merlin/tests/fixtures/testproject/forms.py b/src/merlin/tests/fixtures/testproject/forms.py
index 5898123..9553ad2 100644
--- a/src/merlin/tests/fixtures/testproject/forms.py
+++ b/src/merlin/tests/fixtures/testproject/forms.py
@@ -1,11 +1,32 @@
from django import forms
-
+from django.forms import formsets
+from django.forms.util import ErrorList
class UserDetailsForm(forms.Form):
first_name = forms.CharField()
last_name = forms.CharField()
email = forms.EmailField()
+UserDetailsFormSet = formsets.formset_factory(UserDetailsForm)
+
+class UserDetailsFormSet(formsets.BaseFormSet):
+
+ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
+ initial=None, error_class=ErrorList, form=UserDetailsForm,
+ extra= 1, can_order=False,
+ can_delete=False, max_num=10):
+
+ self.form = form
+ self.extra = extra
+ self.can_order = can_order
+ self.can_delete = can_delete
+ self.max_num = max_num
+
+ super(UserDetailsFormSet, self).__init__(data=data, files=files,
+ auto_id=auto_id, prefix=prefix,
+ initial=initial,
+ error_class=error_class)
+
class ContactDetailsForm(forms.Form):
street_address = forms.CharField()
diff --git a/src/merlin/tests/fixtures/testproject/templates/forms/formset_wizard.html b/src/merlin/tests/fixtures/testproject/templates/forms/formset_wizard.html
new file mode 100644
index 0000000..0cf4165
--- /dev/null
+++ b/src/merlin/tests/fixtures/testproject/templates/forms/formset_wizard.html
@@ -0,0 +1,18 @@
+{% extends "forms/base.html" %}
+
+{% block content %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/merlin/tests/fixtures/testproject/urls.py b/src/merlin/tests/fixtures/testproject/urls.py
index 7f9d6c8..46eb4df 100644
--- a/src/merlin/tests/fixtures/testproject/urls.py
+++ b/src/merlin/tests/fixtures/testproject/urls.py
@@ -1,6 +1,6 @@
from django.conf.urls.defaults import *
-
-from merlin.tests.fixtures.testproject.wizard import MockWizard
+from merlin.tests.fixtures.testproject.wizard import (MockWizard,
+ FormSetWizard)
from merlin.wizards.utils import Step
from merlin.wizards.session import SessionWizard
@@ -17,6 +17,8 @@
url(r'^bettertest/(?P[A-Za-z0-9_-]+)$', MockWizard([
Step('user-details', forms.UserDetailsForm),
Step('contact-details', forms.ContactDetailsForm)])),
+ url(r'^formsettest/(?P[A-Za-z0-9_-]+)$', FormSetWizard([
+ Step('user-details', forms.UserDetailsFormSet),])),
url(r'^$', views.index, name='test-index'),
url(r'^more$', views.more, name='test-more'),
)
diff --git a/src/merlin/tests/fixtures/testproject/wizard.py b/src/merlin/tests/fixtures/testproject/wizard.py
index 9bc5986..e45180a 100644
--- a/src/merlin/tests/fixtures/testproject/wizard.py
+++ b/src/merlin/tests/fixtures/testproject/wizard.py
@@ -5,6 +5,23 @@
from merlin.wizards.utils import Step
+class FormSetWizard(SessionWizard):
+
+ def get_template(self, request, step, form):
+ return "forms/formset_wizard.html"
+
+ def done(self, request):
+ form_data = self.get_form_data(request)
+
+ assert len(form_data['user-details']) == 1
+ assert form_data['user-details'][0]['first_name'] == 'Yorgos'
+ assert form_data['user-details'][0]['last_name'] == 'Pagles'
+ assert form_data['user-details'][0]['email'] == 'yorgos@pagles.org'
+
+ self.clear(request)
+
+ return HttpResponse("All done", mimetype="text/plain")
+
class MockWizard(SessionWizard):
def initialize(self, request, wizard_state):
if not 'global_id' in wizard_state:
diff --git a/src/merlin/tests/test_session_wizard.py b/src/merlin/tests/test_session_wizard.py
index a9351ba..89682d0 100644
--- a/src/merlin/tests/test_session_wizard.py
+++ b/src/merlin/tests/test_session_wizard.py
@@ -35,10 +35,29 @@ def test_type_error_if_step_is_not_type_step(self):
except Exception as e:
self.fail("We should only fail with a TypeError, exception was %s" % e)
+ def test_raises_on_duplicate_steps(self):
+ try:
+ SessionWizard([
+ Step('user-details', forms.UserDetailsForm),
+ Step('user-details', forms.ContactDetailsForm)]
+ )
+ self.fail("We shouldn't be allowed to create a SessionWizard "
+ "with duplicate step names")
+ except ValueError as ve:
+ self.assertEquals(ve.message, 'Step slugs must be unique.')
+
def test_session_wizard_no_slug(self):
response = self.client.get('/simpletest')
self.assertEquals(response.status_code, 404)
+ def test_session_wizard_wrong_slug(self):
+ response = self.client.get('/simpletest/qwerty')
+ self.assertEquals(response.status_code, 404)
+
+ def test_method_not_valid(self):
+ response = self.client.head('/simpletest/user-details')
+ self.assertEquals(response.status_code, 404)
+
def test_form_not_valid(self):
response = self.client.get('/simpletest/user-details')
self.assertEquals(response.status_code, 200)
@@ -153,6 +172,35 @@ def test_session_wizard_cancel_with_redirect(self):
self.assertEquals(response.status_code, 200)
+class FormsetWizardTest(TestCase):
+
+ def test_mock_wizard(self):
+ response = self.client.get('/formsettest/user-details')
+ self.assertEquals(response.status_code, 200)
+
+ soup = BeautifulSoup(response.content)
+
+ self.assertTrue(soup.find('input', id='id_form-TOTAL_FORMS'))
+ self.assertTrue(soup.find('input', id='id_form-INITIAL_FORMS'))
+ self.assertTrue(soup.find('input', id='id_form-MAX_NUM_FORMS'))
+
+ self.assertTrue(soup.find('input', id='id_form-0-first_name'))
+ self.assertTrue(soup.find('input', id='id_form-0-last_name'))
+ self.assertTrue(soup.find('input', id='id_form-0-email'))
+
+ post = self.client.post('/formsettest/user-details', {
+ 'form-TOTAL_FORMS': '1',
+ 'form-INITIAL_FORMS': '0',
+ 'form-MAX_NUM_FORMS': '3',
+ 'form-0-first_name': 'Yorgos',
+ 'form-0-last_name': 'Pagles',
+ 'form-0-email': 'yorgos@pagles.org'
+ }, follow=True)
+
+ self.assertEquals(post.status_code, 200)
+ self.assertEquals(post.content, 'All done')
+
+
class MockWizardTest(TestCase):
def test_mock_wizard(self):
diff --git a/src/merlin/tests/test_utils.py b/src/merlin/tests/test_utils.py
index c1c2841..3334997 100644
--- a/src/merlin/tests/test_utils.py
+++ b/src/merlin/tests/test_utils.py
@@ -1,5 +1,7 @@
import unittest
+from django.forms.formsets import formset_factory
+
from merlin.tests.fixtures.testproject.forms import *
from merlin.wizards.utils import *
@@ -41,6 +43,19 @@ def test_step_object_methods(self):
self.assertEquals('Step: %s' % repr(step1), 'Step: step1')
+ def test_step_with_formset(self):
+ """
+ Just checks that a step can be created using a formset
+ """
+ UserDetailsFormSet = formset_factory(UserDetailsForm)
+ step = Step('step', UserDetailsFormSet)
+
+ self.assertEqual(step.form, None)
+ self.assertEqual(step.formset.__class__,
+ UserDetailsFormSet.__class__)
+ self.assertEquals(str(step), 'step')
+ self.assertEquals(unicode(step), u'step')
+
def test_wizard_expansion(self):
state = WizardState()
diff --git a/src/merlin/wizards/session.py b/src/merlin/wizards/session.py
index f5dba56..e9ef525 100644
--- a/src/merlin/wizards/session.py
+++ b/src/merlin/wizards/session.py
@@ -59,6 +59,7 @@ def __call__(self, request, *args, **kwargs):
Initialize the step list for the session if needed and call the proper
HTTP method handler.
"""
+
self._init_wizard(request)
slug = kwargs.get('slug', None)
@@ -124,6 +125,22 @@ def _show_form(self, request, step, form):
'extra_context': context
})
+ def _show_formset(self, request, step, formset):
+ """
+ Render the provided formset for the provided step to the
+ response stream.
+ """
+ context = self.process_show_formset(request, step, formset)
+
+ return self.render_formset(request, step, formset, {
+ 'current_step': step,
+ 'formset': formset,
+ 'previous_step': self.get_before(request, step),
+ 'next_step': self.get_after(request, step),
+ 'url_base': self._get_URL_base(request, step),
+ 'extra_context': context
+ })
+
def _set_current_step(self, request, step):
"""
Sets the currenlty executing step.
@@ -146,12 +163,31 @@ def process_GET(self, request, step):
"""
form_data = self.get_cleaned_data(request, step)
- if form_data:
- form = step.form(form_data)
+ form = None
+ formset = None
+ if form_data:
+ if step.formset:
+ formset = step.formset(initial=[data for data in form_data if data])
+ else:
+ form = step.form(data=form_data)
else:
- form = step.form()
-
+ if step.formset:
+ initial_data = self.initial_formset_data(request,step,formset)
+ if initial_data:
+ formset = step.formset(initial=initial_data)
+ else:
+ formset = step.formset()
+ else:
+ initial_data = self.initial_form_data(request,step,form)
+ if initial_data:
+ form = step.form(initial=initial_data)
+ else:
+ form = step.form()
+
+ if formset:
+ return self._show_formset(request, step, formset)
+
return self._show_form(request, step, form)
def process_POST(self, request, step):
@@ -160,13 +196,28 @@ def process_POST(self, request, step):
next :class:`Step` in the sequence or finished the wizard process
by calling ``self.done``
"""
- form = step.form(request.POST)
- if not form.is_valid():
+ form = None
+ formset = None
+
+ if step.formset:
+ formset = step.formset(request.POST)
+ else:
+ form = step.form(data=request.POST)
+
+ if form and not form.is_valid():
return self._show_form(request, step, form)
- self.set_cleaned_data(request, step, form.cleaned_data)
- self.process_step(request, step, form)
+ if formset and not formset.is_valid():
+ return self._show_formset(request, step, formset)
+
+ if formset:
+ self.set_cleaned_data(request, step, formset.cleaned_data)
+ self.process_step(request, step, formset)
+ else:
+ self.set_cleaned_data(request, step, form.cleaned_data)
+ self.process_step(request, step, form)
+
next_step = self.get_after(request, step)
if next_step:
@@ -393,6 +444,39 @@ def cancel(self, request):
"""
self.clear(request)
+
+ def initial_form_data(self, request, step, form):
+ """
+ Hook used for providing initial form data.
+
+ :param request:
+ A ``HttpRequest`` object that carries along with it the session
+ used to access the wizard state.
+
+ :param step:
+ The current :class:`Step` that is being processed.
+
+ :param form:
+ The Django ``Form`` object that is being processed.
+ """
+ return None
+
+ def initial_formset_data(self, request, step, formset):
+ """
+ Hook used for providing initial form data.
+
+ :param request:
+ A ``HttpRequest`` object that carries along with it the session
+ used to access the wizard state.
+
+ :param step:
+ The current :class:`Step` that is being processed.
+
+ :param formset:
+ The Django ``Formset`` object that is being processed.
+ """
+ return None
+
def process_show_form(self, request, step, form):
"""
Hook used for providing extra context that can be used in the
@@ -410,6 +494,26 @@ def process_show_form(self, request, step, form):
"""
pass
+
+ def process_show_formset(self, request, step, formset):
+ """
+ Hook used for providing extra context that can be used in the
+ template used to render the current formset.
+
+ :param request:
+ A ``HttpRequest`` object that carries along with it the session
+ used to access the wizard state.
+
+ :param step:
+ The current :class:`Step` that is being processed.
+
+ :param formset:
+ The Django ``BaseFormSet`` object that is being processed.
+ """
+ pass
+
+
+
def process_step(self, request, step, form):
"""
Hook for modifying the ``SessionWizard``'s internal state, given a fully
@@ -427,7 +531,7 @@ def process_step(self, request, step, form):
The current :class:`Step` that is being processed.
:param form:
- The Django ``Form`` object that is being processed.
+ The Django ``Form`` or ``BaseFormSet`` object that is being processed.
"""
pass
@@ -444,7 +548,7 @@ def get_template(self, request, step, form):
The current :class:`Step` that is being processed.
:param form:
- The Django ``Form`` object that is being processed.
+ The Django ``Form`` or ``BaseFormset`` object that is being processed.
"""
return 'forms/wizard.html'
@@ -471,6 +575,31 @@ def render_form(self, request, step, form, context):
return render_to_response(self.get_template(request, step, form),
context, RequestContext(request))
+ def render_formset(self, request, step, formset, context):
+ """
+ Renders a formset with the provided context and returns a ``HttpResponse``
+ object. This can be overridden to provide custom rendering to the
+ client or using a different template engine.
+
+ :param request:
+ A ``HttpRequest`` object that carries along with it the session
+ used to access the wizard state.
+
+ :param step:
+ The current :class:`Step` that is being processed.
+
+ :param formset:
+ The Django ``BaseFormSet`` object that is being processed.
+
+ :param context:
+ The default context that templates can use which also contains
+ any extra context created in the ``process_show_form`` hook.
+ """
+
+
+ return render_to_response(self.get_template(request, step, formset),
+ context, RequestContext(request))
+
def done(self, request):
"""
Responsible for processing the validated form data that the wizard
diff --git a/src/merlin/wizards/utils.py b/src/merlin/wizards/utils.py
index 83e3dc8..e64bd77 100644
--- a/src/merlin/wizards/utils.py
+++ b/src/merlin/wizards/utils.py
@@ -9,11 +9,13 @@
class Step(object):
"""
When constucting a form wizard, the wizard needs to be composed of a
- sequental series of steps in which it is to display forms to the user and
- collect the data from those forms. To be able to provide these forms to the
- :ref:`SessionWizard `, you must first wrap the Django
- :class:`django.forms.Form` in a ``Step`` object. The ``Step`` object gives
- the ability to store the :class:`django.forms.Form` class to be used, as
+ sequental series of steps in which it is to display forms or formsets to the
+ user and collect the data from those forms or formsets.
+ To be able to provide these forms to the :ref:`SessionWizard
+ `, you must first wrap the Django :class:`django.forms
+ .formsets.BaseFormSet` or :class:`django.forms.Form` in a ``Step`` object
+ . The ``Step`` object gives the ability to store the :class:`django.forms
+ .Form` or :class:`django.forms.formsets.BaseFormSet` class to be used, as
well as, a unique slug to be used in the wizard navigation.
.. versionadded:: 0.1
@@ -26,18 +28,26 @@ class Step(object):
:param form:
This *MUST* be a subclass of :class:`django.forms.Form` or
- :class:`django.forms.ModelForm`. This should not be an instance of that
- subclass. The :ref:`SessionWizard ` will use this
- class to create instances for the user. If going back in the wizard
- process, the :ref:`SessionWizard ` will prepopulate
- the form with any cleaned data already collected.
+ :class:`django.forms.ModelForm` or :class:`django.forms.BaseFormSet`.
+ This should not be an instance of that subclass. The
+ :ref:`SessionWizard ` will use
+ this class to create instances for the user. If going back in the
+ wizard process, the :ref:`SessionWizard ` will
+ prepopulate the form or formset with any cleaned data already
+ collected.
"""
def __init__(self, slug, form):
- if not issubclass(form, (forms.Form, forms.ModelForm,)):
- raise ValueError('Form must be subclass of a Django Form')
-
self.slug = str(slug)
- self.form = form
+
+ if issubclass(form, (forms.Form, forms.ModelForm,)):
+ self.form = form
+ self.formset = None
+ elif issubclass(form, (forms.formsets.BaseFormSet,)):
+ self.formset = form
+ self.form = None
+ else:
+ raise ValueError('Form must be subclass of a Django Form or '
+ 'Formset')
def __hash__(self):
return hash(self.slug)