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 %} +
{% csrf_token %} + + {{ formset }} +
+{% if previous_step %} + Back +{% endif %} +{% if next_step %} + +{% else %} + +{% endif %} + +
+{% 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)