239 |
240 | This example assumes that you are using a table to output your data. It should
241 | be trivial to modify this to suite your needs.
242 |
243 | .. currentmodule:: betterforms.views
244 |
245 | .. class:: BrowseView
246 |
247 | Class-based view for working with changelists. It is a combination of
248 | ``FormView`` and ``ListView`` in that it handles form submissions and
249 | providing a optionally paginated queryset for rendering in the template.
250 |
251 | Works similarly to the standard ``FormView`` class provided by django,
252 | except that the form is instantiated using ``request.GET``, and the
253 | ``object_list`` passed into the template context comes from
254 | ``form.get_queryset()``.
255 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | :tocdepth: 1
2 |
3 | .. _changes:
4 |
5 | Changelog
6 | =========
7 |
8 | .. include:: ../CHANGES
9 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # django-betterforms documentation build configuration file, created by
4 | # sphinx-quickstart on Thu May 23 14:33:19 2013.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys
15 | import os
16 |
17 | sys.path.append(os.path.abspath('_themes'))
18 | sys.path.append(os.path.abspath('_ext'))
19 |
20 | # If extensions (or modules to document with autodoc) are in another directory,
21 | # add these directories to sys.path here. If the directory is relative to the
22 | # documentation root, use os.path.abspath to make it absolute, like shown here.
23 | sys.path.insert(0, os.path.abspath('..'))
24 |
25 | # -- General configuration -----------------------------------------------------
26 |
27 | # If your documentation needs a minimal Sphinx version, state it here.
28 | #needs_sphinx = '1.0'
29 |
30 | # Add any Sphinx extension module names here, as strings. They can be extensions
31 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
32 | extensions = ['sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode',
33 | 'sphinx.ext.intersphinx', 'djangodocs']
34 |
35 | # Add any paths that contain templates here, relative to this directory.
36 | templates_path = ['_templates']
37 |
38 | # The suffix of source filenames.
39 | source_suffix = '.rst'
40 |
41 | # The encoding of source files.
42 | #source_encoding = 'utf-8-sig'
43 |
44 | # The master toctree document.
45 | master_doc = 'index'
46 |
47 | # General information about the project.
48 | project = u'django-betterforms'
49 | copyright = u'2013, Fusionbox, Inc.'
50 |
51 | # The version info for the project you're documenting, acts as replacement for
52 | # |version| and |release|, also used in various other places throughout the
53 | # built documents.
54 | from betterforms.version import VERSION, get_version
55 | #
56 | # The short X.Y version.
57 | version = '.'.join(map(str, VERSION[:2]))
58 | # The full version, including alpha/beta/rc tags.
59 | release = get_version()
60 |
61 | # The language for content autogenerated by Sphinx. Refer to documentation
62 | # for a list of supported languages.
63 | #language = None
64 |
65 | # There are two options for replacing |today|: either, you set today to some
66 | # non-false value, then it is used:
67 | #today = ''
68 | # Else, today_fmt is used as the format for a strftime call.
69 | #today_fmt = '%B %d, %Y'
70 |
71 | # List of patterns, relative to source directory, that match files and
72 | # directories to ignore when looking for source files.
73 | exclude_patterns = ['_build']
74 |
75 | # The reST default role (used for this markup: `text`) to use for all documents.
76 | #default_role = None
77 |
78 | # If true, '()' will be appended to :func: etc. cross-reference text.
79 | #add_function_parentheses = True
80 |
81 | # If true, the current module name will be prepended to all description
82 | # unit titles (such as .. function::).
83 | #add_module_names = True
84 |
85 | # If true, sectionauthor and moduleauthor directives will be shown in the
86 | # output. They are ignored by default.
87 | #show_authors = False
88 |
89 | # The name of the Pygments (syntax highlighting) style to use.
90 | pygments_style = 'sphinx'
91 |
92 | # A list of ignored prefixes for module index sorting.
93 | #modindex_common_prefix = []
94 |
95 | # If true, keep warnings as "system message" paragraphs in the built documents.
96 | #keep_warnings = False
97 |
98 |
99 | # -- Options for HTML output ---------------------------------------------------
100 |
101 | # The theme to use for HTML and HTML Help pages. See the documentation for
102 | # a list of builtin themes.
103 | html_theme = 'kr'
104 |
105 | # Theme options are theme-specific and customize the look and feel of a theme
106 | # further. For a list of options available for each theme, see the
107 | # documentation.
108 | #html_theme_options = {}
109 |
110 | # Add any paths that contain custom themes here, relative to this directory.
111 | html_theme_path = ['_themes']
112 |
113 | # The name for this set of Sphinx documents. If None, it defaults to
114 | # " v documentation".
115 | #html_title = None
116 |
117 | # A shorter title for the navigation bar. Default is the same as html_title.
118 | #html_short_title = None
119 |
120 | # The name of an image file (relative to this directory) to place at the top
121 | # of the sidebar.
122 | #html_logo = None
123 |
124 | # The name of an image file (within the static path) to use as favicon of the
125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
126 | # pixels large.
127 | #html_favicon = None
128 |
129 | # Add any paths that contain custom static files (such as style sheets) here,
130 | # relative to this directory. They are copied after the builtin static files,
131 | # so a file named "default.css" will overwrite the builtin "default.css".
132 | html_static_path = ['_static']
133 |
134 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
135 | # using the given strftime format.
136 | #html_last_updated_fmt = '%b %d, %Y'
137 |
138 | # If true, SmartyPants will be used to convert quotes and dashes to
139 | # typographically correct entities.
140 | #html_use_smartypants = True
141 |
142 | # Custom sidebar templates, maps document names to template names.
143 | #html_sidebars = {}
144 |
145 | # Additional templates that should be rendered to pages, maps page names to
146 | # template names.
147 | #html_additional_pages = {}
148 |
149 | # If false, no module index is generated.
150 | #html_domain_indices = True
151 |
152 | # If false, no index is generated.
153 | #html_use_index = True
154 |
155 | # If true, the index is split into individual pages for each letter.
156 | #html_split_index = False
157 |
158 | # If true, links to the reST sources are added to the pages.
159 | #html_show_sourcelink = True
160 |
161 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
162 | #html_show_sphinx = True
163 |
164 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
165 | #html_show_copyright = True
166 |
167 | # If true, an OpenSearch description file will be output, and all pages will
168 | # contain a tag referring to it. The value of this option must be the
169 | # base URL from which the finished HTML is served.
170 | #html_use_opensearch = ''
171 |
172 | # This is the file name suffix for HTML files (e.g. ".xhtml").
173 | #html_file_suffix = None
174 |
175 | # Output file base name for HTML help builder.
176 | htmlhelp_basename = 'django-betterformsdoc'
177 |
178 |
179 | # -- Options for LaTeX output --------------------------------------------------
180 |
181 | latex_elements = {
182 | # The paper size ('letterpaper' or 'a4paper').
183 | #'papersize': 'letterpaper',
184 |
185 | # The font size ('10pt', '11pt' or '12pt').
186 | #'pointsize': '10pt',
187 |
188 | # Additional stuff for the LaTeX preamble.
189 | #'preamble': '',
190 | }
191 |
192 | # Grouping the document tree into LaTeX files. List of tuples
193 | # (source start file, target name, title, author, documentclass [howto/manual]).
194 | latex_documents = [
195 | ('index', 'django-betterforms.tex', u'django-betterforms Documentation',
196 | u'Fusionbox, Inc.', 'manual'),
197 | ]
198 |
199 | # The name of an image file (relative to this directory) to place at the top of
200 | # the title page.
201 | #latex_logo = None
202 |
203 | # For "manual" documents, if this is true, then toplevel headings are parts,
204 | # not chapters.
205 | #latex_use_parts = False
206 |
207 | # If true, show page references after internal links.
208 | #latex_show_pagerefs = False
209 |
210 | # If true, show URL addresses after external links.
211 | #latex_show_urls = False
212 |
213 | # Documents to append as an appendix to all manuals.
214 | #latex_appendices = []
215 |
216 | # If false, no module index is generated.
217 | #latex_domain_indices = True
218 |
219 |
220 | # -- Options for manual page output --------------------------------------------
221 |
222 | # One entry per manual page. List of tuples
223 | # (source start file, name, description, authors, manual section).
224 | man_pages = [
225 | ('index', 'django-betterforms', u'django-betterforms Documentation',
226 | [u'Fusionbox, Inc.'], 1)
227 | ]
228 |
229 | # If true, show URL addresses after external links.
230 | #man_show_urls = False
231 |
232 |
233 | # -- Options for Texinfo output ------------------------------------------------
234 |
235 | # Grouping the document tree into Texinfo files. List of tuples
236 | # (source start file, target name, title, author,
237 | # dir menu entry, description, category)
238 | texinfo_documents = [
239 | ('index', 'django-betterforms', u'django-betterforms Documentation',
240 | u'Fusionbox, Inc.', 'django-betterforms', 'Making django forms suck less.',
241 | 'Miscellaneous'),
242 | ]
243 |
244 | # Documents to append as an appendix to all manuals.
245 | #texinfo_appendices = []
246 |
247 | # If false, no module index is generated.
248 | #texinfo_domain_indices = True
249 |
250 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
251 | #texinfo_show_urls = 'footnote'
252 |
253 | # If true, do not generate a @detailmenu in the "Top" node's menu.
254 | #texinfo_no_detailmenu = False
255 |
256 | intersphinx_mapping = {
257 | 'django': ('https://docs.djangoproject.com/en/1.5/', 'https://docs.djangoproject.com/en/1.5/_objects/'),
258 | }
259 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | django-betterforms
2 | ==================
3 |
4 | Making django forms suck less.
5 |
6 | Contents:
7 |
8 | .. toctree::
9 | :maxdepth: 2
10 |
11 | intro
12 | basics
13 | changelist
14 | multiform
15 | changelog
16 |
17 | Development
18 | -----------
19 |
20 | Development for django-betterforms happens on `GitHub
21 | `_. Pull requests are welcome.
22 | Continuous integration uses `GitHub Actions
23 | `_.
24 |
25 | .. image:: https://github.com/fusionbox/django-betterforms/actions/workflows/ci.yml/badge.svg
26 | :target: https://github.com/fusionbox/django-betterforms/actions/workflows/ci.yml
27 | :alt: Build Status
28 |
--------------------------------------------------------------------------------
/docs/intro.rst:
--------------------------------------------------------------------------------
1 | Introduction
2 | ============
3 |
4 | ``django-betterforms`` provides a suite of tools to make working with forms in
5 | Django easier.
6 |
7 | Installation
8 | ------------
9 |
10 | 1. Install the package::
11 |
12 | $ pip install django-betterforms
13 |
14 | Or you can install it from source::
15 |
16 | $ pip install -e git://github.com/fusionbox/django-betterforms@master#egg=django-betterforms-dev
17 |
18 | 2. Add ``betterforms`` to your ``INSTALLED_APPS``.
19 |
20 |
21 | Quick Start
22 | -----------
23 | Getting started with ``betterforms`` is easy. If you are using the build in
24 | form base classes provided by django, its as simple as switching to the form
25 | base classes provided by ``betterforms``.
26 |
27 | .. code-block:: python
28 |
29 | from betterforms.forms import BetterForm
30 |
31 | class RegistrationForm(BetterForm):
32 | ...
33 | class Meta:
34 | fieldsets = (
35 | ('info', {'fields': ('username', 'email')}),
36 | ('location', {'fields': ('address', ('city', 'state', 'zip'))}),
37 | ('password', {'password1', 'password2'}),
38 | )
39 |
40 | And then in your template.
41 |
42 | .. code-block:: html
43 |
44 |
47 |
48 | Which will render the following.
49 |
50 | .. code-block:: html
51 |
52 |
62 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/docs/multiform.rst:
--------------------------------------------------------------------------------
1 | MultiForm and MultiModelForm
2 | ============================
3 |
4 | .. currentmodule:: betterforms.multiform
5 |
6 | A container that allows you to treat multiple forms as one form. This is great
7 | for using more than one form on a page that share the same submit button.
8 | :class:`MultiForm` imitates the Form API so that it is invisible to anybody
9 | else (for example, generic views) that you are using a :class:`MultiForm`.
10 |
11 | There are a couple of differences, though. One lies in how you initialize the
12 | form. See this example::
13 |
14 | class UserProfileMultiForm(MultiForm):
15 | form_classes = {
16 | 'user': UserForm,
17 | 'profile': ProfileForm,
18 | }
19 |
20 | UserProfileMultiForm(initial={
21 | 'user': {
22 | # User's initial data
23 | },
24 | 'profile': {
25 | # Profile's initial data
26 | },
27 | })
28 |
29 | The initial data argument has to be a nested dictionary so that we can
30 | associate the right initial data with the right form class.
31 |
32 | The other major difference is that there is no direct field access because this
33 | could lead to namespace clashes. You have to access the fields from their
34 | forms. All forms are available using the key provided in
35 | :attr:`~MultiForm.form_classes`::
36 |
37 | form = UserProfileMultiForm()
38 | # get the Field object
39 | form['user'].fields['name']
40 | # get the BoundField object
41 | form['user']['name']
42 |
43 | :class:`MultiForm`, however, does all you to iterate over all the fields of all
44 | the forms.
45 |
46 | .. code-block:: html
47 |
48 | {% for field in form %}
49 | {{ field }}
50 | {% endfor %}
51 |
52 | If you are relying on the fields to come out in a consistent order, you should
53 | use an OrderedDict to define the :attr:`~MultiForm.form_classes`. ::
54 |
55 | from collections import OrderedDict
56 |
57 | class UserProfileMultiForm(MultiForm):
58 | form_classes = OrderedDict((
59 | ('user', UserForm),
60 | ('profile', ProfileForm),
61 | ))
62 |
63 |
64 | Working with ModelForms
65 | -----------------------
66 |
67 | MultiModelForm adds ModelForm support on top of MultiForm. That simply means
68 | that it includes support for the instance parameter in initialization and adds
69 | a save method. ::
70 |
71 | class UserProfileMultiForm(MultiModelForm):
72 | form_classes = {
73 | 'user': UserForm,
74 | 'profile': ProfileForm,
75 | }
76 |
77 | user = User.objects.get(pk=123)
78 | UserProfileMultiForm(instance={
79 | 'user': user,
80 | 'profile': user.profile,
81 | })
82 |
83 |
84 | Working with CreateView
85 | -----------------------
86 |
87 | It is pretty easy to use MultiModelForms with Django's
88 | :class:`~django:django.views.generic.edit.CreateView`, usually you will have to
89 | override the :meth:`~django:django.views.generic.edit.FormMixin.form_valid`
90 | method to do some specific saving functionality. For example, you could have a
91 | signup form that created a user and a user profile object all in one::
92 |
93 | # forms.py
94 | from django import forms
95 | from authtools.forms import UserCreationForm
96 | from betterforms.multiform import MultiModelForm
97 | from .models import UserProfile
98 |
99 | class UserProfileForm(forms.ModelForm):
100 | class Meta:
101 | fields = ('favorite_color',)
102 |
103 | class UserCreationMultiForm(MultiModelForm):
104 | form_classes = {
105 | 'user': UserCreationForm,
106 | 'profile': UserProfileForm,
107 | }
108 |
109 | # views.py
110 | from django.views.generic import CreateView
111 | from django.core.urlresolvers import reverse_lazy
112 | from django.shortcuts import redirect
113 | from .forms import UserCreationMultiForm
114 |
115 | class UserSignupView(CreateView):
116 | form_class = UserCreationMultiForm
117 | success_url = reverse_lazy('home')
118 |
119 | def form_valid(self, form):
120 | # Save the user first, because the profile needs a user before it
121 | # can be saved.
122 | user = form['user'].save()
123 | profile = form['profile'].save(commit=False)
124 | profile.user = user
125 | profile.save()
126 | return redirect(self.get_success_url())
127 |
128 | .. note::
129 |
130 | In this example, we used the ``UserCreationForm`` from the django-authtools
131 | package just for the purposes of brevity. You could of course use any
132 | ModelForm that you wanted to.
133 |
134 | Of course, we could put the save logic in the ``UserCreationMultiForm`` itself
135 | by overriding the :meth:`MultiModelForm.save` method. ::
136 |
137 | class UserCreationMultiForm(MultiModelForm):
138 | form_classes = {
139 | 'user': UserCreationForm,
140 | 'profile': UserProfileForm,
141 | }
142 |
143 | def save(self, commit=True):
144 | objects = super().save(commit=False)
145 |
146 | if commit:
147 | user = objects['user']
148 | user.save()
149 | profile = objects['profile']
150 | profile.user = user
151 | profile.save()
152 |
153 | return objects
154 |
155 | If we do that, we can simplify our view to this::
156 |
157 | class UserSignupView(CreateView):
158 | form_class = UserCreationMultiForm
159 | success_url = reverse_lazy('home')
160 |
161 |
162 | Working with UpdateView
163 | -----------------------
164 |
165 | Working with :class:`~django:django.views.generic.edit.UpdateView` likewise is
166 | quite easy, but you most likely will have to override the
167 | :class:`~django:django.views.generic.edit.FormMixin.get_form_kwargs` method in
168 | order to pass in the instances that you want to work on. If we keep with the
169 | user/profile example, it would look something like this::
170 |
171 | # forms.py
172 | from django import forms
173 | from django.contrib.auth import get_user_model
174 | from betterforms.multiform import MultiModelForm
175 | from .models import UserProfile
176 |
177 | User = get_user_model()
178 |
179 | class UserEditForm(forms.ModelForm):
180 | class Meta:
181 | fields = ('email',)
182 |
183 | class UserProfileForm(forms.ModelForm):
184 | class Meta:
185 | fields = ('favorite_color',)
186 |
187 | class UserEditMultiForm(MultiModelForm):
188 | form_classes = {
189 | 'user': UserEditForm,
190 | 'profile': UserProfileForm,
191 | }
192 |
193 | # views.py
194 | from django.views.generic import UpdateView
195 | from django.core.urlresolvers import reverse_lazy
196 | from django.shortcuts import redirect
197 | from django.contrib.auth import get_user_model
198 | from .forms import UserEditMultiForm
199 |
200 | User = get_user_model()
201 |
202 | class UserSignupView(UpdateView):
203 | model = User
204 | form_class = UserEditMultiForm
205 | success_url = reverse_lazy('home')
206 |
207 | def get_form_kwargs(self):
208 | kwargs = super().get_form_kwargs()
209 | kwargs.update(instance={
210 | 'user': self.object,
211 | 'profile': self.object.profile,
212 | })
213 | return kwargs
214 |
215 |
216 | Working with WizardView
217 | -----------------------
218 |
219 | :class:`MultiForms ` also support the ``WizardView`` classes
220 | provided by django-formtools_, however you must set a
221 | ``base_fields`` attribute on your form class. ::
222 |
223 | # forms.py
224 | from django import forms
225 | from betterforms.multiform import MultiForm
226 |
227 | class Step1Form(MultiModelForm):
228 | # We have to set base_fields to a dictionary because the WizardView
229 | # tries to introspect it.
230 | base_fields = {}
231 |
232 | form_classes = {
233 | 'user': UserEditForm,
234 | 'profile': UserProfileForm,
235 | }
236 |
237 | Then you can use it like normal. ::
238 |
239 | # views.py
240 | from formtools.wizard.views import SessionWizardView
241 | from .forms import Step1Form, Step2Form
242 |
243 | class MyWizardView(SessionWizardView):
244 | def done(self, form_list, form_dict, **kwargs):
245 | step1form = form_dict['1']
246 | # You can get the data for the user form like this:
247 | user = step1form['user'].save()
248 | # ...
249 |
250 | wizard_view = MyWizardView.as_view([Step1Form, Step2Form])
251 |
252 | The reason we have to set ``base_fields`` to a dictionary is that the
253 | ``WizardView`` does some introspection to determine if any of the forms accept
254 | files and then it makes sure that the ``WizardView`` has a ``file_storage`` on
255 | it. By setting ``base_fields`` to an empty dictionary, we can bypass this check.
256 |
257 | .. warning::
258 |
259 | If you have have any forms that accept Files, you must configure the
260 | ``file_storage`` attribute for your WizardView.
261 |
262 | .. _django-formtools: http://django-formtools.readthedocs.org/en/latest/wizard.html
263 |
264 |
265 | API Reference
266 | -------------
267 |
268 | .. class:: MultiForm
269 |
270 | The main interface for customizing :class:`MultiForms ` is
271 | through overriding the :attr:`~MultiForm.form_classes` class attribute.
272 |
273 | Once a MultiForm is instantiated, you can access the child form instances
274 | with their names like this::
275 |
276 | >>> class MyMultiForm(MultiForm):
277 | form_classes = {
278 | 'foo': FooForm,
279 | 'bar': BarForm,
280 | }
281 | >>> forms = MyMultiForm()
282 | >>> foo_form = forms['foo']
283 |
284 | You may also iterate over a multiform to get all of the fields for each
285 | child instance.
286 |
287 | .. rubric:: MultiForm API
288 |
289 | The following attributes and methods are made available for customizing the
290 | instantiation of multiforms.
291 |
292 | .. method:: __init__(*args, **kwargs)
293 |
294 | The :meth:`~MultiForm.__init__` is basically just a pass-through to the
295 | children form classes' initialization methods, the only thing that it
296 | does is provide special handling for the ``initial`` parameter.
297 | Instead of being a dictionary of initial values, ``initial`` is now a
298 | dictionary of form name, initial data pairs. ::
299 |
300 | UserProfileMultiForm(initial={
301 | 'user': {
302 | # User's initial data
303 | },
304 | 'profile': {
305 | # Profile's initial data
306 | },
307 | })
308 |
309 | .. attribute:: form_classes
310 |
311 | This is a dictionary of form name, form class pairs. If the order of
312 | the forms is important (for example for output), you can use an
313 | OrderedDict instead of a plain dictionary.
314 |
315 | .. method:: get_form_args_kwargs(key, args, kwargs)
316 |
317 | This method is available for customizing the instantiation of each form
318 | instance. It should return a two-tuple of args and kwargs that will
319 | get passed to the child form class that corresponds with the key that
320 | is passed in. The default implementation just adds a prefix to each
321 | class to prevent field value clashes.
322 |
323 | .. rubric:: Form API
324 |
325 | The following attributes and methods are made available for mimicking the
326 | :class:`~django:django.forms.Form` API.
327 |
328 | .. attribute:: media
329 |
330 | .. attribute:: is_bound
331 |
332 | .. attribute:: cleaned_data
333 |
334 | Returns an OrderedDict of the ``cleaned_data`` for each of the child
335 | forms.
336 |
337 | .. method:: is_valid
338 |
339 | .. method:: non_field_errors
340 |
341 | .. note::
342 |
343 | :class:`FormSets ` do not
344 | provide :meth:`non_field_errors`, they provide
345 | :meth:`non_form_errors() `,
346 | if you put a :class:`FormSet
347 | ` in your
348 | :attr:`form_classes`, the output of :meth:`non_field_errors` **does
349 | not** include the
350 | :meth:`non_form_errors() `,
351 | from the formset, you will
352 | need to call
353 | :meth:`non_form_errors() `,
354 | yourself.
355 |
356 | .. method:: as_table
357 |
358 | .. method:: as_ul
359 |
360 | .. method:: as_p
361 |
362 | .. method:: is_multipart
363 |
364 | .. method:: hidden_fields
365 |
366 | .. method:: visible_fields
367 |
368 |
369 | .. class:: MultiModelForm
370 |
371 | :class:`MultiModelForm` differs from :class:`MultiForm` only in that adds
372 | special handling for the ``instance`` parameter for initialization and has
373 | a :meth:`~MultiModelForm.save` method.
374 |
375 | .. method:: __init__(*args, **kwargs)
376 |
377 | :class:`MultiModelForm's ` initialization method
378 | provides special handling for the ``instance`` parameter. Instead of
379 | being one object, the ``instance`` parameter is expected to be a
380 | dictionary of form name, instance object pairs. ::
381 |
382 | UserProfileMultiForm(instance={
383 | 'user': user,
384 | 'profile': user.profile,
385 | })
386 |
387 | .. method:: save(commit=True)
388 |
389 | The :meth:`~MultiModelForm.save` method will iterate through the child
390 | classes and call save on each of them. It returns an OrderedDict of
391 | form name, object pairs, where the object is what is returned by the
392 | save method of the child form class. Like the :meth:`ModelForm.save
393 | ` method, if ``commit`` is
394 | ``False``, :meth:`MultiModelForm.save` will add a ``save_m2m`` method
395 | to the :class:`MultiModelForm` instance to aid in saving the
396 | many-to-many relations later.
397 |
398 |
399 | Addendum About django-multiform
400 | -------------------------------
401 |
402 | There is another Django app that provides a similar wrapper called
403 | django-multiform that provides essentially the same features as betterform's
404 | :class:`MultiForm`. I searched for an app that did this feature when I started
405 | work on betterform's version, but couldn't find one. I have looked at
406 | django-multiform now and I think that while they are pretty similar, but there
407 | are some differences which I think should be noted:
408 |
409 | 1. django-multiform's ``MultiForm`` class actually inherits from Django's Form
410 | class. I don't think it is very clear if this is a benefit or a
411 | disadvantage, but to me it seems that it means that there is Form API that
412 | exposed by django-multiform's ``MultiForm`` that doesn't actually delegate
413 | to the child classes.
414 |
415 | 2. I think that django-multiform's method of dispatching the different values
416 | for instance and initial to the child classes is more complicated that it
417 | needs to be. Instead of just accepting a dictionary like betterform's
418 | :class:`MultiForm` does, with django-multiform, you have to write a
419 | `dispatch_init_initial` method.
420 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 | import warnings
5 |
6 | warnings.simplefilter('error')
7 |
8 | if __name__ == "__main__":
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
10 |
11 | from django.core.management import execute_from_command_line
12 |
13 | execute_from_command_line(sys.argv)
14 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | python_files = test_*.py tests.py
3 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from setuptools import setup, find_packages
3 | import os
4 |
5 | __doc__ = """App for Django featuring improved form base classes."""
6 |
7 | version = '2.0.1.dev0'
8 |
9 |
10 | def read(fname):
11 | return open(os.path.join(os.path.dirname(__file__), fname)).read()
12 |
13 |
14 | setup(
15 | name='django-betterforms',
16 | version=version,
17 | description=__doc__,
18 | long_description=read('README.rst'),
19 | url="https://django-betterforms.readthedocs.org/en/latest/",
20 | author="Fusionbox",
21 | author_email='programmers@fusionbox.com',
22 | packages=[package for package in find_packages()
23 | if package.startswith('betterforms')],
24 | install_requires=['Django>=1.11'],
25 | zip_safe=False,
26 | include_package_data=True,
27 | classifiers=[
28 | 'Development Status :: 5 - Production/Stable',
29 | 'Framework :: Django',
30 | 'Intended Audience :: Developers',
31 | 'License :: OSI Approved :: BSD License',
32 | 'Programming Language :: Python',
33 | 'Programming Language :: Python :: 3',
34 | 'Programming Language :: Python :: 3.5',
35 | 'Programming Language :: Python :: 3.6',
36 | 'Programming Language :: Python :: 3.7',
37 | 'Programming Language :: Python :: 3.8',
38 | 'Programming Language :: Python :: 3.9',
39 | 'Programming Language :: Python :: 3.10',
40 | 'Topic :: Internet :: WWW/HTTP',
41 | ],
42 | )
43 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fusionbox/django-betterforms/a3528c84ebf7364fbecf01f337ee884cb55bfa51/tests/__init__.py
--------------------------------------------------------------------------------
/tests/forms.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 |
3 | from django import forms
4 | from django.forms.models import inlineformset_factory
5 | from django.contrib.admin import widgets as admin_widgets
6 | from django.core.exceptions import ValidationError
7 |
8 | from betterforms.multiform import MultiForm, MultiModelForm
9 |
10 | from .models import User, Profile, Badge, Author, Book, BookImage
11 |
12 |
13 | class UserForm(forms.ModelForm):
14 | class Meta:
15 | model = User
16 | fields = ('name',)
17 |
18 |
19 | class ProfileForm(forms.ModelForm):
20 | name = forms.CharField(label='Namespace Clash')
21 |
22 | class Meta:
23 | model = Profile
24 | fields = ('name', 'display_name',)
25 |
26 |
27 | class UserProfileMultiForm(MultiModelForm):
28 | form_classes = OrderedDict((
29 | ('user', UserForm),
30 | ('profile', ProfileForm),
31 | ))
32 |
33 |
34 | class RaisesErrorForm(forms.Form):
35 | name = forms.CharField()
36 | hidden = forms.CharField(widget=forms.HiddenInput)
37 |
38 | class Media:
39 | js = ('test.js',)
40 |
41 | def clean(self):
42 | raise ValidationError('It broke')
43 |
44 |
45 | class ErrorMultiForm(MultiForm):
46 | form_classes = {
47 | 'errors': RaisesErrorForm,
48 | 'errors2': RaisesErrorForm,
49 | }
50 |
51 |
52 | class FileForm(forms.Form):
53 | # we use this widget to test the media property
54 | date = forms.DateTimeField(widget=admin_widgets.AdminSplitDateTime)
55 | image = forms.ImageField()
56 | hidden = forms.CharField(widget=forms.HiddenInput)
57 |
58 |
59 | class NeedsFileField(MultiForm):
60 | form_classes = OrderedDict((
61 | ('file', FileForm),
62 | ('errors', RaisesErrorForm),
63 | ))
64 |
65 |
66 | class BadgeForm(forms.ModelForm):
67 | class Meta:
68 | model = Badge
69 | fields = ('name', 'color',)
70 |
71 |
72 | class BadgeMultiForm(MultiModelForm):
73 | form_classes = {
74 | 'badge1': BadgeForm,
75 | 'badge2': BadgeForm,
76 | }
77 |
78 |
79 | class NonModelForm(forms.Form):
80 | field1 = forms.CharField()
81 |
82 |
83 | class MixedForm(MultiModelForm):
84 | form_classes = {
85 | 'badge': BadgeForm,
86 | 'non_model': NonModelForm,
87 | }
88 |
89 |
90 | class AuthorForm(forms.ModelForm):
91 | class Meta:
92 | model = Author
93 | fields = ('name', 'books',)
94 |
95 |
96 | class ManyToManyMultiForm(MultiModelForm):
97 | form_classes = {
98 | 'badge': BadgeForm,
99 | 'author': AuthorForm,
100 | }
101 |
102 |
103 | class OptionalFileForm(forms.Form):
104 | myfile = forms.FileField(required=False)
105 |
106 |
107 | class Step1Form(MultiModelForm):
108 | # This is required because the WizardView introspects it, but we don't have
109 | # a way of determining this dynamically, so just set it to an empty
110 | # dictionary.
111 | base_fields = {}
112 |
113 | form_classes = {
114 | 'myfile': OptionalFileForm,
115 | 'profile': ProfileForm,
116 | }
117 |
118 |
119 | class Step2Form(forms.Form):
120 | confirm = forms.BooleanField(required=True)
121 |
122 |
123 | class BookForm(forms.ModelForm):
124 | class Meta:
125 | model = Book
126 | fields = ('name',)
127 |
128 |
129 | BookImageFormSet = inlineformset_factory(Book, BookImage, fields=('name',))
130 |
131 |
132 | class BookMultiForm(MultiModelForm):
133 | form_classes = {
134 | 'book': BookForm,
135 | 'images': BookImageFormSet,
136 | }
137 |
138 | def __init__(self, *args, **kwargs):
139 | instance = kwargs.pop('instance', None)
140 | if instance is not None:
141 | kwargs['instance'] = {
142 | 'book': instance,
143 | 'images': instance,
144 | }
145 | super().__init__(*args, **kwargs)
146 |
147 |
148 | class RaisesErrorBookMultiForm(BookMultiForm):
149 | form_classes = {
150 | 'book': BookForm,
151 | 'error': RaisesErrorForm,
152 | 'images': BookImageFormSet,
153 | }
154 |
155 |
156 | class CleanedBookMultiForm(BookMultiForm):
157 | def clean(self):
158 | book = self.cleaned_data['images'][0]['book']
159 | return {
160 | 'images': [
161 | {
162 | 'name': 'Two',
163 | 'book': book,
164 | 'id': None,
165 | 'DELETE': False,
166 | },
167 | {
168 | 'name': 'Three',
169 | 'book': book,
170 | 'id': None,
171 | 'DELETE': False,
172 | },
173 | ],
174 | 'book': {
175 | 'name': 'Overridden',
176 | },
177 | }
178 |
179 |
180 | class RaisesErrorCustomCleanMultiform(UserProfileMultiForm):
181 | def clean(self):
182 | cleaned_data = super(UserProfileMultiForm, self).clean()
183 | raise ValidationError('It broke')
184 | return cleaned_data
185 |
186 |
187 | class ModifiesDataCustomCleanMultiform(UserProfileMultiForm):
188 | def clean(self):
189 | cleaned_data = super(UserProfileMultiForm, self).clean()
190 | cleaned_data['profile']['display_name'] = "cleaned name"
191 | return cleaned_data
192 |
193 |
194 | class RegularForm(forms.Form):
195 | pass
196 |
197 |
198 | class InnerMultiform(MultiForm):
199 | form_classes = {
200 | 'foo3': RegularForm
201 | }
202 |
203 |
204 | class OuterMultiForm(MultiForm):
205 | form_classes = {
206 | 'foo4': InnerMultiform,
207 | }
208 |
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class User(models.Model):
5 | name = models.CharField(max_length=255)
6 | email = models.EmailField()
7 |
8 |
9 | class Profile(models.Model):
10 | user = models.OneToOneField(
11 | User, on_delete=models.CASCADE, related_name='profile',
12 | )
13 | display_name = models.CharField(max_length=255, blank=True)
14 |
15 |
16 | class Badge(models.Model):
17 | name = models.CharField(max_length=255)
18 | color = models.CharField(max_length=20)
19 |
20 |
21 | class Author(models.Model):
22 | name = models.CharField(max_length=255)
23 | books = models.ManyToManyField('Book', related_name='authors')
24 |
25 |
26 | class Book(models.Model):
27 | name = models.CharField(max_length=255)
28 |
29 |
30 | class BookImage(models.Model):
31 | book = models.ForeignKey(
32 | Book, on_delete=models.CASCADE, related_name='images',
33 | )
34 | name = models.CharField(max_length=255)
35 |
--------------------------------------------------------------------------------
/tests/requirements.txt:
--------------------------------------------------------------------------------
1 | django-formtools
2 | pytest
3 | pytest-django
4 | pytest-cov
5 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | DATABASES = {
2 | 'default': {
3 | 'ENGINE': 'django.db.backends.sqlite3',
4 | 'NAME': 'betterforms-tests.db',
5 | },
6 | }
7 |
8 | INSTALLED_APPS = (
9 | 'django.contrib.auth',
10 | 'django.contrib.admin',
11 | 'django.contrib.contenttypes',
12 | 'django.contrib.sessions',
13 | 'django.contrib.staticfiles',
14 | 'betterforms',
15 | 'tests',
16 | )
17 |
18 | DEBUG = True
19 |
20 | SECRET_KEY = 'JVpuGfSgVm2IxJ03xArw5mwmPuYEzAJMbhsTnvLXOPSQR4z93o'
21 |
22 | SITE_ID = 1
23 |
24 | ROOT_URLCONF = 'tests.urls'
25 |
26 | TEMPLATES = [
27 | {
28 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
29 | 'APP_DIRS': True,
30 | },
31 | ]
32 |
33 | MIDDLEWARE = [
34 | 'django.contrib.sessions.middleware.SessionMiddleware',
35 | 'django.middleware.common.CommonMiddleware',
36 | 'django.middleware.csrf.CsrfViewMiddleware',
37 | ]
38 |
39 | STATIC_URL = '/static/'
40 |
41 | USE_TZ = False
42 |
--------------------------------------------------------------------------------
/tests/sqlite_test_settings.py:
--------------------------------------------------------------------------------
1 | from tests.settings import *
2 |
3 | DATABASES = {
4 | 'default': {
5 | 'ENGINE': 'django.db.backends.sqlite3',
6 | 'NAME': '',
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tests/templates/formtools/wizard/wizard_form.html:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fusionbox/django-betterforms/a3528c84ebf7364fbecf01f337ee884cb55bfa51/tests/templates/formtools/wizard/wizard_form.html
--------------------------------------------------------------------------------
/tests/templates/mail/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ content }}
5 |
6 |
7 |
--------------------------------------------------------------------------------
/tests/templates/noop.html:
--------------------------------------------------------------------------------
1 | {# an empty template for testing purposes #}
2 |
--------------------------------------------------------------------------------
/tests/tests.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 |
3 | from django.test import TestCase
4 | from django.test.client import RequestFactory
5 | from django.views.generic import CreateView
6 | try:
7 | from django.utils.encoding import force_str
8 | except ImportError:
9 | # BBB: Django <= 2.2
10 | from django.utils.encoding import force_text as force_str
11 | from django.urls import reverse
12 |
13 | from .models import User, Profile, Badge, Book
14 | from .forms import (
15 | UserProfileMultiForm, BadgeMultiForm, ErrorMultiForm, MixedForm,
16 | NeedsFileField, ManyToManyMultiForm, RaisesErrorBookMultiForm,
17 | CleanedBookMultiForm, BookMultiForm, RaisesErrorCustomCleanMultiform,
18 | ModifiesDataCustomCleanMultiform, OuterMultiForm
19 | )
20 |
21 |
22 | class MultiFormTest(TestCase):
23 | def test_initial_data(self):
24 | form = UserProfileMultiForm(
25 | initial={
26 | 'user': {
27 | 'name': 'foo',
28 | },
29 | 'profile': {
30 | 'display_name': 'bar',
31 | }
32 | }
33 | )
34 |
35 | self.assertEqual(form['user']['name'].value(), 'foo')
36 | self.assertEqual(form['profile']['display_name'].value(), 'bar')
37 |
38 | def test_iter(self):
39 | form = UserProfileMultiForm()
40 | # get the field off of the BoundField
41 | fields = [field.field for field in form]
42 | self.assertEqual(fields, [
43 | form['user'].fields['name'],
44 | form['profile'].fields['name'],
45 | form['profile'].fields['display_name'],
46 | ])
47 |
48 | def test_as_table(self):
49 | form = UserProfileMultiForm()
50 | user_table = form['user'].as_table()
51 | profile_table = form['profile'].as_table()
52 | self.assertEqual(form.as_table(), user_table + profile_table)
53 |
54 | def test_fields(self):
55 | form = UserProfileMultiForm()
56 | self.assertEqual(form.fields, [
57 | 'user-name', 'profile-name', 'profile-display_name'
58 | ])
59 |
60 | def test_errors(self):
61 | form = ErrorMultiForm()
62 | self.assertEqual(form.errors, {})
63 |
64 | def test_errors_crossform(self):
65 | form = ErrorMultiForm()
66 | form.add_crossform_error("Error")
67 | self.assertEqual(form.errors, {'__all__': ['Error']})
68 |
69 | def test_to_str_is_as_table(self):
70 | form = UserProfileMultiForm()
71 | self.assertEqual(force_str(form), form.as_table())
72 |
73 | def test_as_ul(self):
74 | form = UserProfileMultiForm()
75 | user_ul = form['user'].as_ul()
76 | profile_ul = form['profile'].as_ul()
77 | self.assertEqual(form.as_ul(), user_ul + profile_ul)
78 |
79 | def test_as_p(self):
80 | form = UserProfileMultiForm()
81 | user_p = form['user'].as_p()
82 | profile_p = form['profile'].as_p()
83 | self.assertEqual(form.as_p(), user_p + profile_p)
84 |
85 | def test_is_not_valid(self):
86 | form = UserProfileMultiForm({
87 | 'user-name': 'foo',
88 | })
89 | self.assertFalse(form.is_valid())
90 | self.assertTrue(form['user'].is_valid())
91 | self.assertFalse(form['profile'].is_valid())
92 |
93 | form = UserProfileMultiForm({
94 | 'user-name': 'foo',
95 | 'profile-name': 'foo',
96 | })
97 | self.assertTrue(form.is_valid())
98 |
99 | def test_non_field_errors(self):
100 | # we have to pass in a value for data to force real
101 | # validation.
102 | form = ErrorMultiForm(data={})
103 |
104 | self.assertFalse(form.is_valid())
105 | self.assertEqual(form.non_field_errors().as_text(),
106 | '* It broke\n* It broke')
107 |
108 | def test_is_multipart(self):
109 | form1 = ErrorMultiForm()
110 | self.assertFalse(form1.is_multipart())
111 |
112 | form2 = NeedsFileField()
113 | self.assertTrue(form2.is_multipart())
114 |
115 | def test_media(self):
116 | form = NeedsFileField()
117 | self.assertIn('test.js', form.media._js)
118 |
119 | def test_is_bound(self):
120 | form = ErrorMultiForm()
121 | self.assertFalse(form.is_bound)
122 | form = ErrorMultiForm(data={})
123 | self.assertTrue(form.is_bound)
124 |
125 | def test_hidden_fields(self):
126 | form = NeedsFileField()
127 |
128 | hidden_fields = [field.field for field in form.hidden_fields()]
129 |
130 | self.assertEqual(hidden_fields, [
131 | form['file'].fields['hidden'],
132 | form['errors'].fields['hidden'],
133 | ])
134 |
135 | def test_visible_fields(self):
136 | form = NeedsFileField()
137 |
138 | visible_fields = [field.field for field in form.visible_fields()]
139 |
140 | self.assertEqual(visible_fields, [
141 | form['file'].fields['date'],
142 | form['file'].fields['image'],
143 | form['errors'].fields['name'],
144 | ])
145 |
146 | def test_prefix(self):
147 | form = ErrorMultiForm(prefix='foo')
148 | self.assertEqual(form['errors'].prefix, 'errors__foo')
149 |
150 | def test_cleaned_data(self):
151 | form = UserProfileMultiForm({
152 | 'user-name': 'foo',
153 | 'profile-name': 'foo',
154 | })
155 | self.assertTrue(form.is_valid())
156 | self.assertEqual(form.cleaned_data, OrderedDict([
157 | ('user', {
158 | 'name': 'foo',
159 | }),
160 | ('profile', {
161 | 'name': 'foo',
162 | 'display_name': '',
163 | }),
164 | ]))
165 |
166 | def test_handles_none_initial_value(self):
167 | # Used to throw an AttributeError
168 | UserProfileMultiForm(initial=None)
169 |
170 | def test_works_with_wizard_view(self):
171 | url = reverse('test_wizard')
172 | self.client.get(url)
173 |
174 | response = self.client.post(url, {
175 | 'test_wizard_view-current_step': '0',
176 | 'profile__0-name': 'John Doe',
177 | })
178 | view = response.context['view']
179 | self.assertEqual(view.storage.current_step, '1')
180 |
181 | response = self.client.post(url, {
182 | 'test_wizard_view-current_step': '1',
183 | '1-confirm': True,
184 | })
185 | form_list = response.context['form_list']
186 | # In Django>=1.7 on Python 3, form_list is a ValuesView, which doesn't
187 | # support indexing, you are probably recommending to use form_dict
188 | # instead of form_list on Django>=1.7 anyway though.
189 | form_list = list(form_list)
190 | self.assertEqual(form_list[0]['profile'].cleaned_data['name'],
191 | 'John Doe')
192 |
193 | def test_custom_clean_errors(self):
194 | form = RaisesErrorCustomCleanMultiform({
195 | 'user-name': 'foo',
196 | 'profile-name': 'foo',
197 | })
198 | self.assertFalse(form.is_valid())
199 | self.assertEqual(form.cleaned_data, OrderedDict([
200 | ('user', {
201 | 'name': u'foo'
202 | }),
203 | ('profile', {
204 | 'name': u'foo',
205 | 'display_name': u'',
206 | })
207 | ]))
208 | self.assertEqual(form.non_field_errors().as_text(), '* It broke')
209 |
210 | def test_custom_clean_data_change(self):
211 | form = ModifiesDataCustomCleanMultiform({
212 | 'user-name': 'foo',
213 | 'profile-name': 'foo',
214 | 'profile-display_name': 'uncleaned name',
215 | })
216 | self.assertTrue(form.is_valid())
217 | self.assertEqual(form.cleaned_data, OrderedDict([
218 | ('user', {
219 | 'name': u'foo'
220 | }),
221 | ('profile', {
222 | 'name': u'foo',
223 | 'display_name': u'cleaned name',
224 | })
225 | ]))
226 |
227 | def test_multiform_in_multiform(self):
228 | form = OuterMultiForm({
229 | 'user-name': 'foo1',
230 | 'profile-name': 'foo2',
231 | })
232 | form.is_valid()
233 | self.assertTrue(form['foo4'].cleaned_data == OrderedDict([('foo3', {})]))
234 |
235 |
236 | class MultiModelFormTest(TestCase):
237 | def test_save(self):
238 | form = BadgeMultiForm({
239 | 'badge1-name': 'foo',
240 | 'badge1-color': 'blue',
241 | 'badge2-name': 'bar',
242 | 'badge2-color': 'purple',
243 | })
244 |
245 | objects = form.save()
246 | self.assertEqual(objects['badge1'], Badge.objects.get(name='foo'))
247 | self.assertEqual(objects['badge2'], Badge.objects.get(name='bar'))
248 |
249 | def test_save_m2m(self):
250 | book1 = Book.objects.create(name='Foo')
251 | Book.objects.create(name='Bar')
252 |
253 | form = ManyToManyMultiForm({
254 | 'badge-name': 'badge name',
255 | 'badge-color': 'badge color',
256 | 'author-name': 'author name',
257 | 'author-books': [
258 | book1.pk,
259 | ],
260 | })
261 |
262 | self.assertTrue(form.is_valid())
263 |
264 | objects = form.save(commit=False)
265 | objects['badge'].save()
266 | objects['author'].save()
267 | self.assertEqual(objects['author'].books.count(), 0)
268 |
269 | form.save_m2m()
270 | self.assertEqual(objects['author'].books.get(), book1)
271 |
272 | def test_instance(self):
273 | user = User(name='foo')
274 | profile = Profile(display_name='bar')
275 |
276 | # Django checks model equality by checking that the pks are the same.
277 | # A User with no pk is the same as any other User with no pk.
278 | user.pk = 1
279 | profile.pk = 1
280 |
281 | form = UserProfileMultiForm(
282 | instance={
283 | 'user': user,
284 | 'profile': profile,
285 | }
286 | )
287 |
288 | self.assertEqual(form['user'].instance, user)
289 | self.assertEqual(form['profile'].instance, profile)
290 |
291 | def test_model_and_non_model_forms(self):
292 | # This tests that it is possible to instantiate a non-model form using
293 | # the MultiModelForm class too, previously it would explode because it
294 | # was being passed an instance parameter.
295 | MixedForm()
296 |
297 | def test_works_with_create_view_get(self):
298 | viewfn = CreateView.as_view(
299 | form_class=UserProfileMultiForm,
300 | template_name='noop.html',
301 | )
302 | factory = RequestFactory()
303 | request = factory.get('/')
304 | # This would fail as CreateView passes instance=None
305 | viewfn(request)
306 |
307 | def test_works_with_create_view_post(self):
308 | viewfn = CreateView.as_view(
309 | form_class=BadgeMultiForm,
310 | # required after success
311 | success_url='/',
312 | template_name='noop.html',
313 | )
314 | factory = RequestFactory()
315 | request = factory.post('/', data={
316 | 'badge1-name': 'foo',
317 | 'badge1-color': 'blue',
318 | 'badge2-name': 'bar',
319 | 'badge2-color': 'purple',
320 | })
321 | resp = viewfn(request)
322 | self.assertEqual(resp.status_code, 302)
323 | self.assertEqual(Badge.objects.count(), 2)
324 |
325 | def test_is_valid_with_formset(self):
326 | form = BookMultiForm({
327 | 'book-name': 'Test',
328 | 'images-0-name': 'One',
329 | 'images-1-name': 'Two',
330 | 'images-TOTAL_FORMS': '3',
331 | 'images-INITIAL_FORMS': '0',
332 | 'images-MAX_NUM_FORMS': '1000',
333 | })
334 | self.assertTrue(form.is_valid())
335 |
336 | def test_override_clean(self):
337 | form = CleanedBookMultiForm({
338 | 'book-name': 'Test',
339 | 'images-0-name': 'One',
340 | 'images-1-name': 'Two',
341 | 'images-TOTAL_FORMS': '3',
342 | 'images-INITIAL_FORMS': '0',
343 | 'images-MAX_NUM_FORMS': '1000',
344 | })
345 | assert form.is_valid()
346 | assert form['book'].cleaned_data['name'] == 'Overridden'
347 | assert form['images'].forms[0].cleaned_data['name'] == 'Two'
348 | assert form['images'].forms[1].cleaned_data['name'] == 'Three'
349 |
350 | def test_non_field_errors_with_formset(self):
351 | form = RaisesErrorBookMultiForm({
352 | 'book-name': '',
353 | 'images-0-name': '',
354 | 'images-TOTAL_FORMS': '3',
355 | 'images-INITIAL_FORMS': '0',
356 | 'images-MAX_NUM_FORMS': '1000',
357 | })
358 | # assertDoesntRaise AttributeError
359 | self.assertEqual(form.non_field_errors().as_text(), '* It broke')
360 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | try:
2 | from django.urls import re_path
3 | except ImportError:
4 | # BBB: Django <2.0
5 | from django.conf.urls import url as re_path
6 |
7 | from formtools.wizard.views import SessionWizardView
8 |
9 | from .forms import Step1Form, Step2Form
10 |
11 |
12 | class TestWizardView(SessionWizardView):
13 | def done(self, form_list, **kwargs):
14 | context = {
15 | 'form_list': form_list,
16 | }
17 | return self.render_to_response(context)
18 |
19 |
20 | urlpatterns = [
21 | re_path(r'^test-wizard-view/$', TestWizardView.as_view([Step1Form, Step2Form]), name='test_wizard'),
22 | ]
23 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | py{35,36,37}-dj{111,20,21}
4 | py{35,36,37,38,39}-dj22
5 | py{36,37,38,39}-dj{30,31,32}
6 | py{38,39,310}-dj40
7 |
8 | [testenv]
9 | python =
10 | py35: python3.5
11 | py36: python3.6
12 | py37: python3.7
13 | py38: python3.8
14 | py39: python3.9
15 | py310: python3.10
16 | commands = make {posargs:test}
17 | deps =
18 | -r tests/requirements.txt
19 | dj111: Django>=1.11,<1.12
20 | dj20: Django>=2.0,<2.1
21 | dj21: Django>=2.1,<2.2
22 | dj22: Django>=2.2,<2.3
23 | dj30: Django>=3.0,<3.1
24 | dj31: Django>=3.1,<3.2
25 | dj32: Django>=3.2,<3.3
26 | dj40: Django>=4.0,<4.1
27 |
28 | whitelist_externals = make
29 |
--------------------------------------------------------------------------------