From d3d1e36a8e8d53533216e851f1e4196949f873e8 Mon Sep 17 00:00:00 2001 From: Mark Gregson Date: Wed, 10 May 2023 15:01:18 +1000 Subject: [PATCH 1/3] Add tests for callable form conditions --- tests/wizard/test_forms.py | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/wizard/test_forms.py b/tests/wizard/test_forms.py index 5711848..150d59e 100644 --- a/tests/wizard/test_forms.py +++ b/tests/wizard/test_forms.py @@ -1,3 +1,4 @@ +import sys from importlib import import_module from django import forms, http @@ -158,6 +159,46 @@ def test_form_condition(self): response, instance = testform(request) self.assertEqual(instance.get_next_step(), 'step2') + def test_form_condition_can_check_prior_step_data(self): + def step_check(wizard): + wizard.get_cleaned_data_for_step('start') + return False + + testform = TestWizard.as_view( + [('start', Step1), ('step2', Step2), ('step3', Step3)], + condition_dict={'step2': step_check} + ) + request = get_request() + old_limit = sys.getrecursionlimit() + sys.setrecursionlimit(80) + try: + response, instance = testform(request) + self.assertEqual(instance.get_next_step(), 'step3') + except RecursionError: + self.fail("RecursionError happened during wizard test.") + finally: + sys.setrecursionlimit(old_limit) + + def test_form_condition_future_can_check_future_step_data(self): + def subsequent_step_check(wizard): + data = wizard.get_cleaned_data_for_step('step3') or {} + return data.get('foo') + + testform = TestWizard.as_view( + [('start', Step1), ('step2', Step2), ('step3', Step3)], + condition_dict={'step2': subsequent_step_check} + ) + request = get_request() + old_limit = sys.getrecursionlimit() + sys.setrecursionlimit(80) + try: + response, instance = testform(request) + self.assertEqual(instance.get_next_step(), 'step3') + except RecursionError: + self.fail("RecursionError happened during wizard test.") + finally: + sys.setrecursionlimit(old_limit) + def test_form_condition_unstable(self): request = get_request() testform = TestWizard.as_view( From 929fa8d4a539fbe3e0c9c03061a21fc902a6c573 Mon Sep 17 00:00:00 2001 From: Mark Gregson Date: Wed, 10 May 2023 15:01:52 +1000 Subject: [PATCH 2/3] Distinguish static/dynamic form lists by self.form_list If self.form_list is not empty then assume the wizard has been implemented according to the original form list generation strategy (static) and take the form class from there. If self.form_list is empty, however, then get the form class from the dynamically generated list provided by get_form_list(). This allows for the original behaviour as well as form lists that are dynamic and determined solely by get_form_list(). --- formtools/wizard/views.py | 15 ++++++++++++--- tests/wizard/test_forms.py | 5 +---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/formtools/wizard/views.py b/formtools/wizard/views.py index 596d622..f8a6056 100644 --- a/formtools/wizard/views.py +++ b/formtools/wizard/views.py @@ -165,8 +165,6 @@ def get_initkwargs(cls, form_list=None, initial_dict=None, instance_dict=None, computed_form_list = OrderedDict() - assert len(form_list) > 0, 'at least one form is needed' - # walk through the passed form list for i, form in enumerate(form_list): if isinstance(form, (list, tuple)): @@ -395,6 +393,17 @@ def get_form_kwargs(self, step=None): """ return {} + def get_form_class(self, step): + """ + Returns the form class for step. + + If self.form_list is not empty then it is assumed the wizard has been + implemented according to the original form list generation strategy and the form + class is taken from there. If self.form_list is empty, however, then get the + form class from the dynamically generated list provided by get_form_list(). + """ + return self.form_list[step] if self.form_list else self.get_form_list()[step] + def get_form(self, step=None, data=None, files=None): """ Constructs the form for a given `step`. If no `step` is defined, the @@ -406,7 +415,7 @@ def get_form(self, step=None, data=None, files=None): """ if step is None: step = self.steps.current - form_class = self.get_form_list()[step] + form_class = self.get_form_class(step) # prepare the kwargs for the form instance. kwargs = self.get_form_kwargs(step) kwargs.update({ diff --git a/tests/wizard/test_forms.py b/tests/wizard/test_forms.py index 150d59e..98e01fc 100644 --- a/tests/wizard/test_forms.py +++ b/tests/wizard/test_forms.py @@ -93,9 +93,6 @@ def done(self, form_list, **kwargs): class TestWizardWithCustomGetFormList(TestWizard): - - form_list = [Step1] - def get_form_list(self): return {'start': Step1, 'step2': Step2} @@ -318,7 +315,7 @@ def test_get_form_list_default(self): def test_get_form_list_custom(self): request = get_request() - testform = TestWizardWithCustomGetFormList.as_view([('start', Step1)]) + testform = TestWizardWithCustomGetFormList.as_view() response, instance = testform(request) form_list = instance.get_form_list() From 2e466764957a80230ff8f34183d1cbbf36c4ee59 Mon Sep 17 00:00:00 2001 From: Mark Gregson Date: Wed, 10 May 2023 16:15:34 +1000 Subject: [PATCH 3/3] Support custom form class for instantiating forms. Provides a way to retrieve cleaned form data without calling get_form_list() and ending up in infinite recursion. --- formtools/wizard/views.py | 33 +++++++++++++++++++++------------ tests/wizard/test_forms.py | 17 ++++++++++++++--- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/formtools/wizard/views.py b/formtools/wizard/views.py index f8a6056..6f81a83 100644 --- a/formtools/wizard/views.py +++ b/formtools/wizard/views.py @@ -404,7 +404,7 @@ class is taken from there. If self.form_list is empty, however, then get the """ return self.form_list[step] if self.form_list else self.get_form_list()[step] - def get_form(self, step=None, data=None, files=None): + def get_form(self, step=None, data=None, files=None, form_cls=None): """ Constructs the form for a given `step`. If no `step` is defined, the current step will be determined automatically. @@ -412,10 +412,13 @@ def get_form(self, step=None, data=None, files=None): The form will be initialized using the `data` argument to prefill the new form. If needed, instance or queryset (for `ModelForm` or `ModelFormSet`) will be added too. + + If form_cls is provided, this class will be instantiated rather than trying to + retrieve the class from the form list. """ if step is None: step = self.steps.current - form_class = self.get_form_class(step) + form_class = form_cls or self.get_form_class(step) # prepare the kwargs for the form instance. kwargs = self.get_form_kwargs(step) kwargs.update({ @@ -493,21 +496,27 @@ def get_all_cleaned_data(self): cleaned_data.update(form_obj.cleaned_data) return cleaned_data - def get_cleaned_data_for_step(self, step): + def get_cleaned_data_for_step(self, step, form_cls=None): """ Returns the cleaned data for a given `step`. Before returning the cleaned data, the stored values are revalidated through the form. If the data doesn't validate, None will be returned. + + A form_cls can be provided to avoid having to query the class by calling + get_form_list(). This is useful when overriding get_form_list() to create a + dynamic form list but data from other steps is required. """ - if step in self.form_list: - form_obj = self.get_form( - step=step, - data=self.storage.get_step_data(step), - files=self.storage.get_step_files(step), - ) - if form_obj.is_valid(): - return form_obj.cleaned_data - return None + if self.form_list and step not in self.form_list: + return None + form_obj = self.get_form( + step=step, + data=self.storage.get_step_data(step), + files=self.storage.get_step_files(step), + form_cls=form_cls, + ) + if not form_obj.is_valid(): + return None + return form_obj.cleaned_data def get_next_step(self, step=None): """ diff --git a/tests/wizard/test_forms.py b/tests/wizard/test_forms.py index 98e01fc..c3fbb9e 100644 --- a/tests/wizard/test_forms.py +++ b/tests/wizard/test_forms.py @@ -1,4 +1,5 @@ import sys +from collections import OrderedDict from importlib import import_module from django import forms, http @@ -94,7 +95,10 @@ def done(self, form_list, **kwargs): class TestWizardWithCustomGetFormList(TestWizard): def get_form_list(self): - return {'start': Step1, 'step2': Step2} + form_list = OrderedDict([('start', Step1), ('step2', Step2)]) + self.get_cleaned_data_for_step("step2", form_cls=Step2) + form_list["step3"] = Step3 + return form_list class FormTests(TestCase): @@ -318,8 +322,15 @@ def test_get_form_list_custom(self): testform = TestWizardWithCustomGetFormList.as_view() response, instance = testform(request) - form_list = instance.get_form_list() - self.assertEqual(form_list, {'start': Step1, 'step2': Step2}) + old_limit = sys.getrecursionlimit() + sys.setrecursionlimit(80) + try: + form_list = instance.get_form_list() + except RecursionError: + self.fail("RecursionError happened during wizard test.") + finally: + sys.setrecursionlimit(old_limit) + self.assertEqual(form_list, {'start': Step1, 'step2': Step2, 'step3': Step3}) self.assertIsInstance(instance.get_form('step2'), Step2)