├── .coveragerc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── RELEASES.rst ├── betterforms ├── __init__.py ├── changelist.py ├── forms.py ├── models.py ├── multiform.py ├── templates │ └── betterforms │ │ ├── field_as_div.html │ │ ├── field_as_p.html │ │ ├── fieldset_as_div.html │ │ ├── fieldset_as_p.html │ │ ├── form_as_fieldsets.html │ │ ├── form_as_p.html │ │ └── sort_form_header.html ├── templatetags │ ├── __init__.py │ └── betterforms_tags.py ├── tests.py └── views.py ├── docs ├── Makefile ├── _ext │ └── djangodocs.py ├── _themes │ └── kr │ │ ├── layout.html │ │ ├── relations.html │ │ ├── static │ │ ├── flasky.css_t │ │ └── small_flask.css │ │ └── theme.conf ├── basics.rst ├── changelist.rst ├── changelog.rst ├── conf.py ├── index.rst ├── intro.rst └── multiform.rst ├── manage.py ├── pytest.ini ├── setup.py ├── tests ├── __init__.py ├── forms.py ├── models.py ├── requirements.txt ├── settings.py ├── sqlite_test_settings.py ├── templates │ ├── formtools │ │ └── wizard │ │ │ └── wizard_form.html │ ├── mail │ │ └── base.html │ └── noop.html ├── tests.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *tests* 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | # Django 1.11 12 | - django-version: "1.11.0" 13 | python-version: "3.5" 14 | - django-version: "1.11.0" 15 | python-version: "3.6" 16 | - django-version: "1.11.0" 17 | python-version: "3.7" 18 | # Django 2.0 19 | - django-version: "2.0.0" 20 | python-version: "3.5" 21 | - django-version: "2.0.0" 22 | python-version: "3.6" 23 | - django-version: "2.0.0" 24 | python-version: "3.7" 25 | # Django 2.1 26 | - django-version: "2.1.0" 27 | python-version: "3.5" 28 | - django-version: "2.1.0" 29 | python-version: "3.6" 30 | - django-version: "2.1.0" 31 | python-version: "3.7" 32 | # Django 2.2 33 | - django-version: "2.2.0" 34 | python-version: "3.5" 35 | - django-version: "2.2.0" 36 | python-version: "3.6" 37 | - django-version: "2.2.0" 38 | python-version: "3.7" 39 | - django-version: "2.2.0" 40 | python-version: "3.8" 41 | - django-version: "2.2.0" 42 | python-version: "3.9" 43 | # Django 3.0 44 | - django-version: "3.0.0" 45 | python-version: "3.6" 46 | - django-version: "3.0.0" 47 | python-version: "3.7" 48 | - django-version: "3.0.0" 49 | python-version: "3.8" 50 | - django-version: "3.0.0" 51 | python-version: "3.9" 52 | # Django 3.1 53 | - django-version: "3.1.0" 54 | python-version: "3.6" 55 | - django-version: "3.1.0" 56 | python-version: "3.7" 57 | - django-version: "3.1.0" 58 | python-version: "3.8" 59 | - django-version: "3.1.0" 60 | python-version: "3.9" 61 | # Django 3.2 62 | - django-version: "3.2.0" 63 | python-version: "3.6" 64 | - django-version: "3.2.0" 65 | python-version: "3.7" 66 | - django-version: "3.2.0" 67 | python-version: "3.8" 68 | - django-version: "3.2.0" 69 | python-version: "3.9" 70 | # Django 4.0 71 | - django-version: "4.0.0" 72 | python-version: "3.8" 73 | - django-version: "4.0.0" 74 | python-version: "3.9" 75 | - django-version: "4.0.0" 76 | python-version: "3.10" 77 | 78 | steps: 79 | - uses: actions/checkout@v3 80 | 81 | - name: Set up Python ${{ matrix.python-version }} 82 | uses: actions/setup-python@v3 83 | with: 84 | python-version: ${{ matrix.python-version }} 85 | 86 | - name: Upgrade pip version 87 | run: python -m pip install -U pip 88 | 89 | - name: Install django version and install dependencies 90 | run: python -m pip install "Django~=${{ matrix.django-version }}" -e . -r tests/requirements.txt 91 | 92 | - name: Run Tests 93 | run: | 94 | make test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | build/ 4 | docs/_build/ 5 | tests/sqlite_database 6 | tests/.coverage 7 | tests/htmlcov/ 8 | dist/ 9 | .tox/ 10 | .cache/ 11 | .coverage 12 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 2.0.1 (unreleased) 2 | ------------------ 3 | 4 | - Nothing changed yet. 5 | 6 | 7 | 2.0.0 (2022-08-11) 8 | ------------------ 9 | Backwards-incompatible changes: 10 | 11 | - Removed support for python 2.7 and 3.4 12 | - Removed support for Django versions 1.8, 1.9, and 1.10. 13 | 14 | New Features and Bugfixes: 15 | 16 | - Add support for Django 2.1, 2.2, 3.0, 3.1, 3.2, and 4.0. 17 | - Add support for Python 3.7, 3.8, 3.9, and 3.10. 18 | - Multiline bug fix for SetupTools (#61 and #67) 19 | - Nested multiforms cleaned_data bug fix (#69) 20 | 21 | 22 | 1.2 (2018-07-03) 23 | ---------------- 24 | 25 | - Add support and tests for Django 1.10, 1.11, 2.0 26 | - Bugfixes 27 | 28 | 29 | 1.1.4 (2016-01-15) 30 | ------------------ 31 | 32 | - Bugfix for nested multiforms 33 | 34 | 35 | 1.1.3 (2016-01-11) 36 | ------------------ 37 | 38 | - (Bugfix) Help text is not HTML-escaped anymore in Django 1.8 39 | - 40 | - Remove support for Django 1.5 and 1.6. 41 | - Add support for Multiform crossform validation [Julian Andrews, #35]. 42 | 43 | 44 | 1.1.2 (2015-09-21) 45 | ------------------ 46 | 47 | - (Bugfix) Mark the output of MultiForm as safe HTML. [Frankie Robertson] 48 | 49 | 1.1.1 (2014-08-22) 50 | ------------------ 51 | 52 | - (Bugfix) Output both the prefixed and non-prefixed name when the Form is prefixed. [Rocky Meza, #17] 53 | 54 | 1.1.0 (2014-08-04) 55 | ------------------ 56 | 57 | - Output required for fields even on forms that don't define required_css_class [#16] 58 | 59 | 1.0.1 (2014-07-07) 60 | ------------------ 61 | 62 | - (Bugfix) Handle None initial values more robustly in MultiForm 63 | 64 | 1.0.0 (2014-07-04) 65 | ------------------ 66 | 67 | Backwards-incompatible changes: 68 | 69 | - Moved all the partials to live the betterforms directory 70 | - Dropped support for Django 1.3 71 | 72 | New Features and Bugfixes: 73 | 74 | - Support Python 3 75 | - Support Django 1.6 76 | - Add MultiForm and MultiModelForm 77 | - Make NonBraindamagedErrorMixin use error_class 78 | - Use NON_FIELD_ERRORS constant instead of hardcoded value 79 | - Add csrf_exempt argument for form_as_fieldsets 80 | - Add legend attribute to Fieldset 81 | 82 | 0.1.3 (2013-10-17) 83 | ------------------ 84 | 85 | - Add ``betterforms.changelist`` module with form classes that assist in 86 | listing, searching and filtering querysets. 87 | 88 | 0.1.2 (2013-07-25) 89 | ------------------ 90 | 91 | * actually update the package. 92 | 93 | 0.1.1 (2013-07-25) 94 | ------------------ 95 | 96 | * fix form rendering for forms with no fieldsets 97 | 98 | 0.1.0 (2013-07-25) 99 | ------------------ 100 | 101 | Initial Release 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Fusionbox, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include CHANGES.rst 4 | recursive-include betterforms/templates * 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SETTINGS=tests.sqlite_test_settings 2 | COVERAGE_ARGS= 3 | 4 | test: test-builtin 5 | 6 | test-builtin: 7 | DJANGO_SETTINGS_MODULE=$(SETTINGS) py.test $(COVERAGE_ARGS) 8 | 9 | coverage: 10 | +make test COVERAGE_ARGS='--cov-config .coveragerc --cov-report html --cov-report= --cov=betterforms' 11 | 12 | docs: 13 | cd docs && $(MAKE) html 14 | 15 | .PHONY: test test-builtin coverage docs 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-betterforms 2 | ------------------ 3 | 4 | .. image:: https://github.com/fusionbox/django-betterforms/actions/workflows/ci.yml/badge.svg 5 | :target: https://github.com/fusionbox/django-betterforms/actions/workflows/ci.yml 6 | :alt: Build Status 7 | 8 | .. image:: https://coveralls.io/repos/fusionbox/django-betterforms/badge.png 9 | :target: http://coveralls.io/r/fusionbox/django-betterforms 10 | :alt: Build Status 11 | 12 | `django-betterforms` builds on the built-in django forms. 13 | 14 | 15 | Installation 16 | ============ 17 | 18 | 1. Install the package:: 19 | 20 | $ pip install django-betterforms 21 | 22 | 2. Add ``betterforms`` to your ``INSTALLED_APPS``. 23 | 24 | 25 | -------------------------------------------------------------------------------- /RELEASES.rst: -------------------------------------------------------------------------------- 1 | Release Process 2 | =============== 3 | 4 | django-betterforms uses `zest.releaser`_ to manage releases. For a fuller 5 | understanding of the release process, please read zest.releaser's 6 | documentation, this document is more of a cheat sheet. 7 | 8 | Getting Setup 9 | ------------- 10 | 11 | You will need to install zest.releaser:: 12 | 13 | $ pip install zest.releaser 14 | 15 | Then create and configure a ``.pypirc`` file in your home directory to have the PyPI 16 | credentials. Ask one of the other Fusionbox Programmers how to do that. 17 | 18 | Releases 19 | -------- 20 | 21 | The process for releases is the same regardless of whether it's a patch, minor, 22 | or major release. It is as follows. 23 | 24 | 1. Add the changes to ``CHANGES.rst`` if you have not already done so. Don't commit. NOTE: You do not have to replace "(unreleased)" 25 | with the desired release date; zest.releaser will do this automatically. 26 | 2. Run the ``tox`` command to make sure that the ``README.rst`` and 27 | ``CHANGES.rst`` files are valid. 28 | 3. Commit changes with a commit message like "CHANGES for 1.1.0". 29 | 4. Run the ``fullrelease`` command. You will be prompted through several pre- and post-release decisions. 30 | 31 | 32 | 33 | Editing the Changelog 34 | --------------------- 35 | 36 | Editing the changelog is very important. It is where we write down all of our 37 | release notes and upgrade instructions. Please spend time when editing the 38 | changelog. 39 | 40 | One way to help getting the changes for new versions is to run the following 41 | commands:: 42 | 43 | $ git tag | sort -rn # figure out the latest tag (imagine it's 1.0.0) 44 | 1.0.0 45 | $ git log HEAD ^1.0.0 46 | 47 | This will show all the commits that are in HEAD that weren't in the last 48 | release. 49 | 50 | If possible, it's nice to add a credit line with the author's name and the 51 | issue number of GitHub. 52 | 53 | Deciding on a Version Number 54 | ---------------------------- 55 | 56 | Here are some nominal guidelines for deciding on version numbers when cutting 57 | releases. If you feel the need to deviate from them, go ahead. If you find 58 | yourself deviating every time, please update this document. 59 | 60 | This is not semver, but it's similar. 61 | 62 | Patch Release (1.0.x) 63 | ^^^^^^^^^^^^^^^^^^^^^ 64 | 65 | Bug fixes, documentation, and general project maintenance. 66 | 67 | Avoid backwards incompatible changes like the plague. 68 | 69 | Minor Release (1.x.0) 70 | ^^^^^^^^^^^^^^^^^^^^^ 71 | 72 | New features, and anything in patch releases. 73 | 74 | Try to avoid backwards incompatible changes, but if you feel like you need 75 | (especially for security), it's acceptable. 76 | 77 | Major Release (x.0.0) 78 | ^^^^^^^^^^^^^^^^^^^^^ 79 | 80 | Really Cool New Features, and anything that you include in a minor release. 81 | 82 | Backwards incompatibility is more acceptable here, although still frowned upon. 83 | 84 | 85 | Additional Reading 86 | ------------------ 87 | 88 | - `zest.releaser Version handling `_ 89 | - `PEP 396 - Module Version Numbers `_ 90 | - `PEP 440 - Version Identification and Dependency Specification `_ 91 | 92 | .. _zest.releaser: http://zestreleaser.readthedocs.org/ -------------------------------------------------------------------------------- /betterforms/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = __import__('pkg_resources').get_distribution('django-betterforms').version 2 | -------------------------------------------------------------------------------- /betterforms/changelist.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from django import forms 4 | from django.forms.utils import pretty_name 5 | from django.core.exceptions import ValidationError, ImproperlyConfigured 6 | from django.db.models import Q 7 | from collections import OrderedDict 8 | from functools import reduce 9 | from django.utils.http import urlencode 10 | 11 | from .forms import BetterForm 12 | 13 | 14 | def construct_querystring(data, **kwargs): 15 | params = copy.copy(data) 16 | 17 | # We can't call update here because QueryDict extends rather than replaces. 18 | for key, value in kwargs.items(): 19 | params[key] = value 20 | 21 | if hasattr(params, 'urlencode'): 22 | return params.urlencode() 23 | else: 24 | return urlencode(params) 25 | 26 | 27 | class IterDict(OrderedDict): 28 | """ 29 | Extension of djangos built in sorted dictionary class which iterates 30 | through the values rather than keys. 31 | """ 32 | def __iter__(self): 33 | for key in super().__iter__(): 34 | yield self[key] 35 | 36 | 37 | class BaseChangeListForm(BetterForm): 38 | """ 39 | Base class for all ``ChangeListForms``. 40 | """ 41 | def __init__(self, *args, **kwargs): 42 | """ 43 | Takes an option named argument ``queryset`` as the base queryset used in 44 | the ``get_queryset`` method. 45 | """ 46 | try: 47 | self.base_queryset = kwargs.pop('queryset', None) 48 | if self.base_queryset is None: 49 | self.base_queryset = self.model.objects.all() 50 | except AttributeError: 51 | raise AttributeError('`ChangeListForm`s must be instantiated with a\ 52 | queryset, or have a `model` attribute set on\ 53 | them') 54 | super().__init__(*args, **kwargs) 55 | 56 | def get_queryset(self): 57 | """ 58 | If the form was initialized with a queryset, this method returns that 59 | queryset. Otherwise it returns ``Model.objects.all()`` for whatever 60 | model was defined for the form. 61 | """ 62 | return self.base_queryset 63 | 64 | 65 | class SearchForm(BaseChangeListForm): 66 | SEARCH_FIELDS = None 67 | CASE_SENSITIVE = False 68 | q = forms.CharField(label="Search", required=False) 69 | 70 | def __init__(self, *args, **kwargs): 71 | self.SEARCH_FIELDS = kwargs.pop('search_fields', self.SEARCH_FIELDS) 72 | super().__init__(*args, **kwargs) 73 | 74 | if self.SEARCH_FIELDS is None: 75 | raise ImproperlyConfigured('`SearchForm`s must be instantiated with an\ 76 | iterable of fields to search over, or have \ 77 | a `SEARCH_FIELDS` attribute set on them.') 78 | 79 | def get_queryset(self): 80 | """ 81 | Constructs an '__contains' or '__icontains' filter across all of the 82 | fields listed in ``SEARCH_FIELDS``. 83 | """ 84 | qs = super().get_queryset() 85 | 86 | # Do Searching 87 | q = self.cleaned_data.get('q', '').strip() 88 | if q: 89 | args = [] 90 | for field in self.SEARCH_FIELDS: 91 | if self.CASE_SENSITIVE: 92 | kwarg = {field + '__contains': q} 93 | else: 94 | kwarg = {field + '__icontains': q} 95 | args.append(Q(**kwarg)) 96 | if len(args) > 1: 97 | qs = qs.filter(reduce(lambda x, y: x | y, args)) 98 | elif len(args) == 1: 99 | qs = qs.filter(args[0]) 100 | 101 | return qs 102 | 103 | 104 | class BoundHeader: 105 | def __init__(self, form, header): 106 | self.form = form 107 | self.header = header 108 | self.sorts = getattr(form, 'cleaned_data', {}).get('sorts', []) 109 | self.param = "{0}-sorts".format(form.prefix or '').strip('-') 110 | 111 | @property 112 | def name(self): 113 | return self.header.name 114 | 115 | @property 116 | def label(self): 117 | return self.header.label 118 | 119 | @property 120 | def column_name(self): 121 | return self.header.column_name 122 | 123 | @property 124 | def is_sortable(self): 125 | return self.header.is_sortable 126 | 127 | @property 128 | def _index(self): 129 | return self.form.HEADERS.index(self.header) 130 | 131 | @property 132 | def _sort_index(self): 133 | """ 134 | 1-indexed value for what number represents this header in the sorts 135 | querystring parameter. 136 | """ 137 | return self._index + 1 138 | 139 | @property 140 | def is_active(self): 141 | """ 142 | Returns whether this header is currently being used for sorting. 143 | """ 144 | return self._sort_index in map(abs, self.sorts) 145 | 146 | @property 147 | def is_ascending(self): 148 | """ 149 | Returns whether this header is currently being used for sorting in 150 | ascending order. 151 | """ 152 | return self.is_active and self._sort_index in self.sorts 153 | 154 | @property 155 | def is_descending(self): 156 | """ 157 | Returns whether this header is currently being used for sorting in 158 | descending order. 159 | """ 160 | return self.is_active and self._sort_index not in self.sorts 161 | 162 | @property 163 | def css_classes(self): 164 | """ 165 | String suitable to be used for the `class` attribute for an HTML 166 | element. Denotes whether this header is active in the sorts, and the 167 | order in which it is being used. 168 | """ 169 | classes = [] 170 | if self.is_active: 171 | classes.append('active') 172 | if self.is_ascending: 173 | classes.append('ascending') 174 | elif self.is_descending: 175 | classes.append('descending') 176 | return ' '.join(classes) 177 | 178 | def add_to_sorts(self): 179 | """ 180 | Compute the sorts that should be used when we're clicked on. If we're 181 | currently in the sorts, we'll be set as the first sort [ascending]. 182 | Unless we're already at the front then we'll be inverted. 183 | """ 184 | if self.sorts and abs(self.sorts[0]) == self._sort_index: 185 | return [-1 * self.sorts[0]] + self.sorts[1:] 186 | else: 187 | return [self._sort_index] + list(filter(lambda x: abs(x) != self._sort_index, self.sorts)) 188 | 189 | @property 190 | def priority(self): 191 | if self.is_active: 192 | return list(map(abs, self.sorts)).index(self._sort_index) + 1 193 | 194 | @property 195 | def querystring(self): 196 | return construct_querystring(self.form.data, **{self.param: '.'.join(map(str, self.add_to_sorts()))}) 197 | 198 | @property 199 | def singular_querystring(self): 200 | if self.is_active and abs(self.sorts[0]) == self._sort_index: 201 | value = -1 * self._sort_index 202 | else: 203 | value = self._sort_index 204 | return construct_querystring(self.form.data, **{self.param: str(value)}) 205 | 206 | @property 207 | def remove_querystring(self): 208 | return construct_querystring(self.form.data, **{self.param: '.'.join(map(str, self.add_to_sorts()[1:]))}) 209 | 210 | 211 | class Header: 212 | BoundClass = BoundHeader 213 | column_name = None 214 | 215 | def __init__(self, name, label=None, column_name=False, is_sortable=True): 216 | self.name = name 217 | self.label = label or pretty_name(name) 218 | if is_sortable: 219 | self.column_name = column_name or name 220 | self.is_sortable = is_sortable 221 | 222 | 223 | def is_header_kwargs(header): 224 | try: 225 | if not len(header) == 2: 226 | return False 227 | except AttributeError: 228 | return False 229 | try: 230 | return all(( 231 | isinstance(header[0], str), 232 | isinstance(header[1], dict), 233 | )) 234 | except (IndexError, KeyError): 235 | return False 236 | 237 | 238 | class HeaderSet: 239 | HeaderClass = Header 240 | 241 | def __init__(self, form, headers): 242 | self.form = form 243 | self.headers = OrderedDict() 244 | if headers is None: 245 | return 246 | for header in headers: 247 | if isinstance(header, Header): 248 | self.headers[header.name] = header 249 | elif isinstance(header, str): 250 | self.headers[header] = self.HeaderClass(header) 251 | elif is_header_kwargs(header): 252 | header_name, header_kwargs = header 253 | self.headers[header_name] = self.HeaderClass(header_name, **header_kwargs) 254 | elif len(header): 255 | try: 256 | header_name = header[0] 257 | header_args = header[1:] 258 | self.headers[header_name] = self.HeaderClass(header_name, *header_args) 259 | except KeyError: 260 | raise ImproperlyConfigured('Unknown format in header declaration: `{0}`'.format(repr(header))) 261 | else: 262 | raise ImproperlyConfigured('Unknown format in header declaration: `{0}`'.format(repr(header))) 263 | if not len(self) == len(headers): 264 | raise ImproperlyConfigured('Header names must be unique') 265 | 266 | def __len__(self): 267 | return len(self.headers) 268 | 269 | def __iter__(self): 270 | for header in self.headers.values(): 271 | yield self.HeaderClass.BoundClass(self.form, header) 272 | 273 | def __getitem__(self, key): 274 | if isinstance(key, int): 275 | return self.HeaderClass.BoundClass(self.form, list(self.headers.values())[key]) 276 | else: 277 | return self.HeaderClass.BoundClass(self.form, self.headers[key]) 278 | 279 | 280 | class SortFormBase(BetterForm): 281 | """ 282 | A base class for writing your own SortForm. This form handles everything 283 | except applying the sorts to the queryset, which is convenient if you 284 | aren't working within the ChangeListForm paradigm. 285 | 286 | Usage:: 287 | 288 | class MyForm(SortFormBase): 289 | HEADERS = ( 290 | Header('name', label='Name'), 291 | ) 292 | 293 | # fields ... 294 | 295 | def get_results(self): 296 | queryset = # ... 297 | queryset = self.apply_sorting(queryset) 298 | return queryset 299 | """ 300 | HeaderSetClass = HeaderSet 301 | error_messages = { 302 | 'unknown_header': 'Invalid sort parameter', 303 | 'unsortable_header': 'Invalid sort parameter', 304 | } 305 | HEADERS = None 306 | sorts = forms.CharField(required=False, widget=forms.HiddenInput()) 307 | 308 | def __init__(self, *args, **kwargs): 309 | super().__init__(*args, **kwargs) 310 | self.headers = self.HeaderSetClass(self, self.HEADERS) 311 | 312 | def clean_sorts(self): 313 | cleaned_data = self.cleaned_data 314 | sorts = list(filter(bool, cleaned_data.get('sorts', '').split('.'))) 315 | if not sorts: 316 | return [] 317 | # Ensure that the sort parameter does not contain non-numeric sort indexes 318 | if not all([sort.strip('-').isdigit() for sort in sorts]): 319 | raise ValidationError(self.error_messages['unknown_header']) 320 | sorts = [int(sort) for sort in sorts] 321 | # Ensure that all of our sort parameters are in range of our header values 322 | if any([abs(sort) > len(self.HEADERS) for sort in sorts]): 323 | raise ValidationError(self.error_messages['unknown_header']) 324 | # Ensure not un-sortable fields are being sorted by 325 | if not all(self.HEADERS[abs(i) - 1].is_sortable for i in sorts): 326 | raise ValidationError(self.error_messages['unsortable_header']) 327 | 328 | return sorts 329 | 330 | def get_order_by(self): 331 | # Do Sorting 332 | sorts = self.cleaned_data.get('sorts', []) 333 | order_by = [] 334 | for sort in sorts: 335 | param = self.headers[abs(sort) - 1].column_name 336 | if sort < 0: 337 | param = '-' + param 338 | order_by.append(param) 339 | return order_by 340 | 341 | def apply_sorting(self, qs): 342 | order_by = self.get_order_by() 343 | if order_by: 344 | qs = qs.order_by(*order_by) 345 | return qs 346 | 347 | 348 | class SortForm(BaseChangeListForm, SortFormBase): 349 | def get_queryset(self): 350 | """ 351 | Returns an ordered queryset, sorted based on the values submitted in 352 | the sort parameter. 353 | """ 354 | qs = super().get_queryset() 355 | return self.apply_sorting(qs) 356 | -------------------------------------------------------------------------------- /betterforms/forms.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections.abc import Iterable 3 | except AttributeError: 4 | # BBB Python < 3.9 5 | from collections import Iterable 6 | from collections import Counter, OrderedDict 7 | 8 | from django import forms 9 | from django.forms.utils import ErrorDict 10 | from django.core.exceptions import NON_FIELD_ERRORS 11 | from django.template.loader import render_to_string 12 | 13 | 14 | class CSSClassMixin: 15 | """ 16 | Sane defaults for error and css classes. 17 | """ 18 | error_css_class = 'error' 19 | required_css_class = 'required' 20 | 21 | 22 | class NonBraindamagedErrorMixin: 23 | """ 24 | Form mixin for easier field based error messages. 25 | """ 26 | def field_error(self, name, error): 27 | self._errors = self._errors or ErrorDict() 28 | self._errors.setdefault(name, self.error_class()) 29 | self._errors[name].append(error) 30 | 31 | def form_error(self, error): 32 | self.field_error(NON_FIELD_ERRORS, error) 33 | 34 | 35 | class LabelSuffixMixin: 36 | """ 37 | Form mixin to make it possible to override the label_suffix at class 38 | declaration. Django's built-in Form class only allows you to override the 39 | label_suffix at instantiation or by overriding __init__. A value of None 40 | will use the default Django provided suffix (':' in English). If you wish 41 | to have no label_suffix, you can set label_suffix to and empty string. For 42 | example, 43 | 44 | class NoLabelSuffixMixin(LabelSuffixMixin): 45 | label_suffix = '' 46 | """ 47 | label_suffix = None 48 | 49 | def __init__(self, *args, **kwargs): 50 | kwargs.setdefault('label_suffix', self.label_suffix) 51 | super().__init__(*args, **kwargs) 52 | 53 | 54 | def process_fieldset_row(fields, fieldset_class, base_name): 55 | for index, row in enumerate(fields): 56 | if not isinstance(row, (str, Fieldset)): 57 | if len(row) == 2 and isinstance(row[0], str) and isinstance(row[1], dict): 58 | row = fieldset_class(row[0], **row[1]) 59 | else: 60 | row = fieldset_class("{0}_{1}".format(base_name, index), fields=row) 61 | yield row 62 | 63 | 64 | def flatten(elements): 65 | """ 66 | Flattens a mixed list of strings and iterables of strings into a single 67 | iterable of strings. 68 | """ 69 | for element in elements: 70 | if isinstance(element, Iterable) and not isinstance(element, str): 71 | for sub_element in flatten(element): 72 | yield sub_element 73 | else: 74 | yield element 75 | 76 | 77 | flatten_to_tuple = lambda x: tuple(flatten(x)) 78 | 79 | 80 | class Fieldset(CSSClassMixin): 81 | FIELDSET_CSS_CLASS = 'formFieldset' 82 | css_classes = None 83 | template_name = None 84 | 85 | def __init__(self, name, fields=[], **kwargs): 86 | self.name = name 87 | self.base_fields = tuple(process_fieldset_row(fields, type(self), name)) 88 | self.legend = kwargs.pop("legend", None) 89 | # Check for duplicate names. 90 | names = [str(thing) for thing in self.base_fields] 91 | duplicates = [x for x, y in Counter(names).items() if y > 1] 92 | if duplicates: 93 | raise AttributeError('Name Conflict in fieldset `{0}`. The name(s) `{1}` appear multiple times.'.format(self.name, duplicates)) 94 | for key, value in kwargs.items(): 95 | setattr(self, key, value) 96 | 97 | def __iter__(self): 98 | return iter(self.base_fields) 99 | 100 | def __bool__(self): 101 | return bool(self.base_fields) 102 | 103 | def __str__(self): 104 | return self.name 105 | 106 | @property 107 | def fields(self): 108 | return flatten_to_tuple(self) 109 | 110 | 111 | class BoundFieldset: 112 | is_fieldset = True 113 | 114 | def __init__(self, form, fieldset, name): 115 | self.form = form 116 | self.name = name 117 | self.fieldset = fieldset 118 | self.rows = OrderedDict() 119 | for row in fieldset: 120 | self.rows[str(row)] = row 121 | 122 | def __getitem__(self, key): 123 | """ 124 | >>> fieldset[1] 125 | # returns the item at index-1 in the fieldset 126 | >>> fieldset['name'] 127 | # returns the item in the fieldset under the key 'name' 128 | """ 129 | if isinstance(key, int) and not key in self.rows: 130 | return self[list(self.rows.keys())[key]] 131 | value = self.rows[key] 132 | if isinstance(value, str): 133 | return self.form[value] 134 | else: 135 | return type(self)(self.form, value, key) 136 | 137 | def __str__(self): 138 | env = { 139 | 'fieldset': self, 140 | 'form': self.form, 141 | 'fieldset_template_name': 'betterforms/fieldset_as_div.html', 142 | } 143 | # TODO: don't hardcode the default template name. 144 | return render_to_string(self.template_name or 'betterforms/fieldset_as_div.html', env) 145 | 146 | def __iter__(self): 147 | for name in self.rows.keys(): 148 | yield self[name] 149 | 150 | @property 151 | def template_name(self): 152 | return self.fieldset.template_name 153 | 154 | @property 155 | def errors(self): 156 | return self.form.errors.get(self.name, self.form.error_class()) 157 | 158 | @property 159 | def css_classes(self): 160 | css_classes = set((self.fieldset.FIELDSET_CSS_CLASS, self.name)) 161 | css_classes.update(self.fieldset.css_classes or []) 162 | if self.errors: 163 | css_classes.add(self.fieldset.error_css_class) 164 | return ' '.join(css_classes) 165 | 166 | @property 167 | def legend(self): 168 | return self.fieldset.legend 169 | 170 | 171 | class FieldsetMixin(NonBraindamagedErrorMixin): 172 | template_name = None 173 | fieldset_class = Fieldset 174 | bound_fieldset_class = BoundFieldset 175 | base_fieldsets = None 176 | 177 | @property 178 | def fieldsets(self): 179 | if self.base_fieldsets is None: 180 | return self.bound_fieldset_class(self, self.fields.keys(), '__base_fieldset__') 181 | return self.bound_fieldset_class(self, self.base_fieldsets, self.base_fieldsets.name) 182 | 183 | def __getitem__(self, key): 184 | try: 185 | return super().__getitem__(key) 186 | except KeyError: 187 | return self.fieldsets[key] 188 | 189 | def __iter__(self): 190 | for fieldset in self.fieldsets: 191 | yield fieldset 192 | # return iter(self.fieldsets) 193 | 194 | # These methods need to be implemented to render the fieldsets and fields 195 | # in a similar structure as `BaseForm` 196 | def __str__(self): 197 | return self.as_table() 198 | 199 | def as_table(self): 200 | raise NotImplementedError('To be implemented') 201 | 202 | def as_ul(self): 203 | raise NotImplementedError('To be implemented') 204 | 205 | def as_p(self): 206 | env = { 207 | 'form': self, 208 | 'fieldset_template_name': 'betterforms/fieldset_as_p.html', 209 | 'field_template_name': 'betterforms/field_as_p.html', 210 | } 211 | return render_to_string(self.template_name or 'betterforms/form_as_p.html', env) 212 | 213 | 214 | def get_fieldsets(bases, attrs): 215 | try: 216 | return attrs['Meta'].fieldsets 217 | except (KeyError, AttributeError): 218 | for base in bases: 219 | fieldsets = getattr(base, 'base_fieldsets', None) 220 | if fieldsets is not None: 221 | return fieldsets 222 | return None 223 | 224 | 225 | def get_fieldset_class(bases, attrs): 226 | if 'fieldset_class' in attrs: 227 | return attrs['fieldset_class'] 228 | else: 229 | for base in bases: 230 | try: 231 | return base.fieldset_class 232 | except AttributeError: 233 | continue 234 | return Fieldset 235 | 236 | 237 | class BetterModelFormMetaclass(forms.models.ModelFormMetaclass): 238 | def __new__(cls, name, bases, attrs): 239 | base_fieldsets = get_fieldsets(bases, attrs) 240 | if base_fieldsets is not None: 241 | FieldsetClass = get_fieldset_class(bases, attrs) 242 | base_fieldsets = FieldsetClass('__base_fieldset__', fields=base_fieldsets) 243 | attrs['base_fieldsets'] = base_fieldsets 244 | Meta = attrs.get('Meta') 245 | if Meta and Meta.__dict__.get('fields') is None and Meta.__dict__.get('exclude') is None: 246 | attrs['Meta'].fields = flatten_to_tuple(base_fieldsets) 247 | attrs['base_fieldsets'] = base_fieldsets 248 | return super().__new__(cls, name, bases, attrs) 249 | 250 | 251 | class BetterModelForm(FieldsetMixin, LabelSuffixMixin, CSSClassMixin, forms.ModelForm, metaclass=BetterModelFormMetaclass): 252 | pass 253 | 254 | 255 | class BetterFormMetaClass(forms.forms.DeclarativeFieldsMetaclass): 256 | def __new__(cls, name, bases, attrs): 257 | base_fieldsets = get_fieldsets(bases, attrs) 258 | if base_fieldsets is not None: 259 | FieldsetClass = get_fieldset_class(bases, attrs) 260 | base_fieldsets = FieldsetClass('__base_fieldset__', fields=base_fieldsets) 261 | attrs['base_fieldsets'] = base_fieldsets 262 | return super().__new__(cls, name, bases, attrs) 263 | 264 | 265 | class BetterForm(FieldsetMixin, LabelSuffixMixin, CSSClassMixin, forms.forms.BaseForm, metaclass=BetterFormMetaClass): 266 | """ 267 | A 'Better' base Form class. 268 | """ 269 | -------------------------------------------------------------------------------- /betterforms/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fusionbox/django-betterforms/a3528c84ebf7364fbecf01f337ee884cb55bfa51/betterforms/models.py -------------------------------------------------------------------------------- /betterforms/multiform.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from operator import add 3 | from collections import OrderedDict 4 | 5 | from django.forms import BaseFormSet 6 | from django.forms.utils import ErrorList 7 | from django.core.exceptions import ValidationError, NON_FIELD_ERRORS 8 | from django.utils.safestring import mark_safe 9 | from functools import reduce 10 | 11 | 12 | class MultiForm: 13 | """ 14 | A container that allows you to treat multiple forms as one form. This is 15 | great for using more than one form on a page that share the same submit 16 | button. MultiForm imitates the Form API so that it is invisible to anybody 17 | else that you are using a MultiForm. 18 | """ 19 | form_classes = {} 20 | 21 | def __init__(self, data=None, files=None, *args, **kwargs): 22 | # Some things, such as the WizardView expect these to exist. 23 | self.data, self.files = data, files 24 | kwargs.update( 25 | data=data, 26 | files=files, 27 | ) 28 | 29 | self.initials = kwargs.pop('initial', None) 30 | if self.initials is None: 31 | self.initials = {} 32 | self.forms = OrderedDict() 33 | self.crossform_errors = [] 34 | 35 | for key, form_class in self.form_classes.items(): 36 | fargs, fkwargs = self.get_form_args_kwargs(key, args, kwargs) 37 | self.forms[key] = form_class(*fargs, **fkwargs) 38 | 39 | def get_form_args_kwargs(self, key, args, kwargs): 40 | """ 41 | Returns the args and kwargs for initializing one of our form children. 42 | """ 43 | fkwargs = kwargs.copy() 44 | prefix = kwargs.get('prefix') 45 | if prefix is None: 46 | prefix = key 47 | else: 48 | prefix = '{0}__{1}'.format(key, prefix) 49 | fkwargs.update( 50 | initial=self.initials.get(key), 51 | prefix=prefix, 52 | ) 53 | return args, fkwargs 54 | 55 | def __str__(self): 56 | return self.as_table() 57 | 58 | def __getitem__(self, key): 59 | return self.forms[key] 60 | 61 | @property 62 | def errors(self): 63 | errors = {} 64 | for form_name in self.forms: 65 | form = self.forms[form_name] 66 | for field_name in form.errors: 67 | errors[form.add_prefix(field_name)] = form.errors[field_name] 68 | if self.crossform_errors: 69 | errors[NON_FIELD_ERRORS] = self.crossform_errors 70 | return errors 71 | 72 | @property 73 | def fields(self): 74 | fields = [] 75 | for form_name in self.forms: 76 | form = self.forms[form_name] 77 | for field_name in form.fields: 78 | fields += [form.add_prefix(field_name)] 79 | return fields 80 | 81 | def __iter__(self): 82 | # TODO: Should the order of the fields be controllable from here? 83 | return chain.from_iterable(self.forms.values()) 84 | 85 | @property 86 | def is_bound(self): 87 | return any(form.is_bound for form in self.forms.values()) 88 | 89 | def clean(self): 90 | """ 91 | Raises any ValidationErrors required for cross form validation. Should 92 | return a dict of cleaned_data objects for any forms whose data should 93 | be overridden. 94 | """ 95 | return self.cleaned_data 96 | 97 | def add_crossform_error(self, e): 98 | self.crossform_errors.append(e) 99 | 100 | def is_valid(self): 101 | forms_valid = all(form.is_valid() for form in self.forms.values()) 102 | try: 103 | self.cleaned_data = self.clean() 104 | except ValidationError as e: 105 | self.add_crossform_error(e) 106 | return forms_valid and not self.crossform_errors 107 | 108 | def non_field_errors(self): 109 | form_errors = ( 110 | form.non_field_errors() for form in self.forms.values() 111 | if hasattr(form, 'non_field_errors') 112 | ) 113 | return ErrorList(chain(self.crossform_errors, *form_errors)) 114 | 115 | def as_table(self): 116 | return mark_safe(''.join(form.as_table() for form in self.forms.values())) 117 | 118 | def as_ul(self): 119 | return mark_safe(''.join(form.as_ul() for form in self.forms.values())) 120 | 121 | def as_p(self): 122 | return mark_safe(''.join(form.as_p() for form in self.forms.values())) 123 | 124 | def is_multipart(self): 125 | return any(form.is_multipart() for form in self.forms.values()) 126 | 127 | @property 128 | def media(self): 129 | return reduce(add, (form.media for form in self.forms.values())) 130 | 131 | def hidden_fields(self): 132 | # copy implementation instead of delegating in case we ever 133 | # want to override the field ordering. 134 | return [field for field in self if field.is_hidden] 135 | 136 | def visible_fields(self): 137 | return [field for field in self if not field.is_hidden] 138 | 139 | @property 140 | def cleaned_data(self): 141 | return OrderedDict( 142 | (key, form.cleaned_data) 143 | for key, form in self.forms.items() if form.is_valid() 144 | ) 145 | 146 | @cleaned_data.setter 147 | def cleaned_data(self, data): 148 | for key, value in data.items(): 149 | child_form = self[key] 150 | if isinstance(child_form, BaseFormSet): 151 | for formlet, formlet_data in zip(child_form.forms, value): 152 | formlet.cleaned_data = formlet_data 153 | else: 154 | child_form.cleaned_data = value 155 | 156 | 157 | class MultiModelForm(MultiForm): 158 | """ 159 | MultiModelForm adds ModelForm support on top of MultiForm. That simply 160 | means that it includes support for the instance parameter in initialization 161 | and adds a save method. 162 | """ 163 | def __init__(self, *args, **kwargs): 164 | self.instances = kwargs.pop('instance', None) 165 | if self.instances is None: 166 | self.instances = {} 167 | super().__init__(*args, **kwargs) 168 | 169 | def get_form_args_kwargs(self, key, args, kwargs): 170 | fargs, fkwargs = super().get_form_args_kwargs(key, args, kwargs) 171 | try: 172 | # If we only pass instance when there was one specified, we make it 173 | # possible to use non-ModelForms together with ModelForms. 174 | fkwargs['instance'] = self.instances[key] 175 | except KeyError: 176 | pass 177 | return fargs, fkwargs 178 | 179 | def save(self, commit=True): 180 | objects = OrderedDict( 181 | (key, form.save(commit)) 182 | for key, form in self.forms.items() 183 | ) 184 | 185 | if any(hasattr(form, 'save_m2m') for form in self.forms.values()): 186 | def save_m2m(): 187 | for form in self.forms.values(): 188 | if hasattr(form, 'save_m2m'): 189 | form.save_m2m() 190 | self.save_m2m = save_m2m 191 | 192 | return objects 193 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/field_as_div.html: -------------------------------------------------------------------------------- 1 | {% load betterforms_tags %} 2 | {% if field.is_hidden %} 3 | {{ field }} 4 | {% else %} 5 |
6 | {% if not field|is_checkbox %} 7 | {{ field.label_tag }} 8 | {% endif %} 9 | 10 | {% if field.help_text %} 11 |

{{ field.help_text|safe }}

12 | {% endif %} 13 | 14 | {{ field }} 15 | {% if field|is_checkbox %} 16 | {{ field.label_tag }} 17 | {% endif %} 18 | {{ field.errors }} 19 |
20 | {% endif %} 21 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/field_as_p.html: -------------------------------------------------------------------------------- 1 | {% if field.is_hidden %} 2 | {{ field }} 3 | {% else %} 4 | 5 | {{ field.errors }} 6 | {{ field.label_tag }} 7 | {{ field }} 8 | {% if field.help_text %} 9 | {{ field.help_text }} 10 | {% endif %} 11 |

12 | {% endif %} 13 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/fieldset_as_div.html: -------------------------------------------------------------------------------- 1 | {% if fieldset.template_name %} 2 | {% include fieldset.template_name %} 3 | {% else %} 4 |
5 | {% if fieldset.legend %} 6 | {{ fieldset.legend }} 7 | {% endif %} 8 | {{ fieldset.errors }} 9 | {% for thing in fieldset %} 10 | {% if thing.is_fieldset %} 11 | {% include fieldset_template_name with fieldset=thing %} 12 | {% else %} 13 | {% include field_template_name with field=thing %} 14 | {% endif %} 15 | {% endfor %} 16 |
17 | {% endif %} 18 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/fieldset_as_p.html: -------------------------------------------------------------------------------- 1 | {% extends "betterforms/fieldset_as_div.html" %} 2 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/form_as_fieldsets.html: -------------------------------------------------------------------------------- 1 | {% block form_head %} 2 | {% if not no_head %} 3 | {% if not csrf_exempt %} 4 | {% csrf_token %} 5 | {% endif %} 6 | {% if next %} 7 | 8 | {% endif %} 9 | {{ form.media }} 10 | {% endif %} 11 | {% endblock %} 12 | 13 | {% block form_body %} 14 | {{ form.non_field_errors }} 15 | {# Hack to allow recursive template inclusion #} 16 | {% with fieldset_template_name="betterforms/fieldset_as_div.html" field_template_name="betterforms/field_as_div.html" %} 17 | {% for thing in form %} 18 | {% if thing.is_fieldset %} 19 | {% include fieldset_template_name with fieldset=thing %} 20 | {% else %} 21 | {% include field_template_name with field=thing %} 22 | {% endif %} 23 | {% endfor %} 24 | {% endwith %} 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/form_as_p.html: -------------------------------------------------------------------------------- 1 | {% extends 'betterforms/form_as_fieldsets.html' %} 2 | 3 | {% block form_body %} 4 | {{ form.non_field_errors }} 5 | {# Hack to allow recursive template inclusion #} 6 | {% for thing in form %} 7 | {% include fieldset_template_name with fieldset=thing %} 8 | {% endfor %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /betterforms/templates/betterforms/sort_form_header.html: -------------------------------------------------------------------------------- 1 | 2 | {% if header.is_sortable %} 3 | {{ header.label }} 4 | {% if header.is_active %} 5 | {% if header.is_ascending %} 6 | ▾ 7 | {% elif header.is_descending %} 8 | ▴ 9 | {% endif %} 10 | 11 | {{ header.priority }} x 12 | {% endif %} 13 | {% else %} 14 | {{ header.label }} 15 | {% endif %} 16 | 17 | -------------------------------------------------------------------------------- /betterforms/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fusionbox/django-betterforms/a3528c84ebf7364fbecf01f337ee884cb55bfa51/betterforms/templatetags/__init__.py -------------------------------------------------------------------------------- /betterforms/templatetags/betterforms_tags.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django import template 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter(name='is_checkbox') 8 | def is_checkbox(field): 9 | """ 10 | Boolean filter for form fields to determine if a field is using a checkbox 11 | widget. 12 | """ 13 | return isinstance(field.field.widget, forms.CheckboxInput) 14 | -------------------------------------------------------------------------------- /betterforms/tests.py: -------------------------------------------------------------------------------- 1 | import unittest # NOQA 2 | 3 | from unittest import mock 4 | 5 | import django 6 | from django import forms 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.conf import settings 9 | from django.db import models 10 | from django.test import TestCase 11 | from django.template.loader import render_to_string 12 | from django.http import QueryDict 13 | 14 | from betterforms.changelist import ( 15 | BaseChangeListForm, SearchForm, SortForm, HeaderSet, Header, BoundHeader 16 | ) 17 | from betterforms.forms import ( 18 | BetterForm, BetterModelForm, Fieldset, BoundFieldset, flatten_to_tuple, 19 | ) 20 | 21 | 22 | class TestUtils(TestCase): 23 | def test_flatten(self): 24 | fields1 = ('a', 'b', 'c') 25 | self.assertTupleEqual(flatten_to_tuple(fields1), fields1) 26 | 27 | fields2 = ('a', ('b', 'c'), 'd') 28 | self.assertTupleEqual(flatten_to_tuple(fields2), ('a', 'b', 'c', 'd')) 29 | 30 | fields3 = ('a', ('b', 'c'), 'd', ('e', ('f', 'g', ('h',)), 'i')) 31 | self.assertTupleEqual(flatten_to_tuple(fields3), ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i')) 32 | 33 | 34 | class TestFieldSets(TestCase): 35 | def test_basic_fieldset(self): 36 | fields = ('a', 'b', 'c') 37 | fieldset = Fieldset('the_name', fields=fields) 38 | self.assertEqual(fieldset.name, 'the_name') 39 | self.assertTupleEqual(fields, fieldset.fields) 40 | 41 | def test_nested_fieldset(self): 42 | fields = ('a', ('b', 'c'), 'd') 43 | fieldset = Fieldset('the_name', fields=fields) 44 | self.assertTupleEqual(flatten_to_tuple(fields), fieldset.fields) 45 | iterated = tuple(iter(fieldset)) 46 | self.assertEqual(iterated[0], 'a') 47 | self.assertTupleEqual(iterated[1].fields, ('b', 'c')) 48 | self.assertEqual(iterated[2], 'd') 49 | 50 | def test_named_nested_fieldset(self): 51 | fields = ('a', ('sub_name', {'fields': ('b', 'c')}), 'd') 52 | fieldset = Fieldset('the_name', fields=fields) 53 | self.assertTupleEqual(fieldset.fields, ('a', 'b', 'c', 'd')) 54 | fieldsets = tuple(iter(fieldset)) 55 | self.assertEqual(fieldsets[0], 'a') 56 | self.assertTupleEqual(fieldsets[1].fields, ('b', 'c')) 57 | self.assertEqual(fieldsets[1].name, 'sub_name') 58 | self.assertEqual(fieldsets[2], 'd') 59 | 60 | def test_deeply_nested_fieldsets(self): 61 | fields = ('a', ('b', 'c'), 'd', ('e', ('f', 'g', ('h',)), 'i')) 62 | fieldset = Fieldset('the_name', fields=fields) 63 | self.assertTupleEqual(flatten_to_tuple(fields), fieldset.fields) 64 | 65 | def test_fieldset_as_row_item(self): 66 | fields = ('a', Fieldset('sub_name', fields=['b', 'c'])) 67 | fieldset = Fieldset('the_name', fields=fields) 68 | self.assertTupleEqual(fieldset.fields, ('a', 'b', 'c')) 69 | 70 | def test_nonzero_fieldset(self): 71 | fieldset1 = Fieldset('the_name', fields=[]) 72 | self.assertFalse(fieldset1) 73 | 74 | fieldset2 = Fieldset('the_name', fields=['a']) 75 | self.assertTrue(fieldset2) 76 | 77 | def test_assigning_template_name(self): 78 | fieldset1 = Fieldset('the_name', fields=['a']) 79 | self.assertIsNone(fieldset1.template_name) 80 | fieldset2 = Fieldset('the_name', fields=['a'], template_name='some_custom_template.html') 81 | self.assertEqual(fieldset2.template_name, 'some_custom_template.html') 82 | 83 | 84 | class TestFieldsetDeclarationSyntax(TestCase): 85 | def test_admin_style_declaration(self): 86 | class TestForm(BetterForm): 87 | a = forms.CharField() 88 | b = forms.CharField() 89 | c = forms.CharField() 90 | d = forms.CharField() 91 | 92 | class Meta: 93 | fieldsets = ( 94 | ('first', {'fields': ('a',)}), 95 | ('second', {'fields': ('b', 'c')}), 96 | ('third', {'fields': ('d',)}), 97 | ) 98 | form = TestForm() 99 | fieldsets = [fieldset for fieldset in form.fieldsets] 100 | self.assertEqual(fieldsets[0].name, 'first') 101 | self.assertTupleEqual(fieldsets[0].fieldset.fields, ('a',)) 102 | self.assertEqual(fieldsets[1].name, 'second') 103 | self.assertTupleEqual(fieldsets[1].fieldset.fields, ('b', 'c')) 104 | self.assertEqual(fieldsets[2].name, 'third') 105 | self.assertTupleEqual(fieldsets[2].fieldset.fields, ('d',)) 106 | self.assertIsInstance(fieldsets[0], BoundFieldset) 107 | 108 | def test_bare_fields_style_declaration(self): 109 | class TestForm(BetterForm): 110 | a = forms.CharField() 111 | b = forms.CharField() 112 | c = forms.CharField() 113 | d = forms.CharField() 114 | 115 | class Meta: 116 | fieldsets = ('a', ('b', 'c'), 'd') 117 | form = TestForm() 118 | fieldsets = [fieldset for fieldset in form.fieldsets] 119 | self.assertEqual(fieldsets[0].field, form.fields['a']) 120 | self.assertEqual(fieldsets[1].name, '__base_fieldset___1') 121 | self.assertTupleEqual(fieldsets[1].fieldset.fields, ('b', 'c')) 122 | self.assertEqual(fieldsets[2].field, form.fields['d']) 123 | self.assertIsInstance(fieldsets[0], forms.BoundField) 124 | self.assertIsInstance(fieldsets[1], BoundFieldset) 125 | self.assertIsInstance(fieldsets[2], forms.BoundField) 126 | 127 | 128 | class TestBetterForm(TestCase): 129 | def setUp(self): 130 | class TestForm(BetterForm): 131 | a = forms.CharField() 132 | b = forms.CharField() 133 | c = forms.CharField() 134 | 135 | class Meta: 136 | fieldsets = ( 137 | ('first', {'fields': ('a', 'b')}), 138 | ('second', {'fields': ('c',)}), 139 | ) 140 | self.TestForm = TestForm 141 | 142 | def test_name_lookups(self): 143 | form = self.TestForm() 144 | fieldsets = [fieldset for fieldset in form.fieldsets] 145 | # field lookups 146 | self.assertEqual(form['a'].field, fieldsets[0]['a'].field) 147 | # fieldset lookups 148 | self.assertEqual(form['first'].fieldset, fieldsets[0].fieldset) 149 | self.assertEqual(form['second'].fieldset, fieldsets[1].fieldset) 150 | 151 | def test_index_lookups(self): 152 | form = self.TestForm() 153 | # field lookups 154 | self.assertEqual(form['a'].field, form.fieldsets[0][0].field) 155 | # fieldset lookups 156 | self.assertEqual(form['first'].fieldset, form.fieldsets[0].fieldset) 157 | self.assertEqual(form['second'].fieldset, form.fieldsets[1].fieldset) 158 | 159 | def test_field_to_fieldset_name_conflict(self): 160 | with self.assertRaises(AttributeError): 161 | class NameConflictForm(self.TestForm): 162 | class Meta: 163 | fieldsets = ( 164 | ('first', {'fields': ('a', 'b')}), 165 | ('first', {'fields': ('c',)}), 166 | ) 167 | 168 | def test_duplicate_name_in_fieldset(self): 169 | with self.assertRaises(AttributeError): 170 | class NameConflictForm(self.TestForm): 171 | class Meta: 172 | fieldsets = ( 173 | ('first', {'fields': ('a', 'a')}), 174 | ('second', {'fields': ('c',)}), 175 | ) 176 | 177 | def test_field_error(self): 178 | data = {'a': 'a', 'b': 'b', 'c': 'c'} 179 | form = self.TestForm(data) 180 | self.assertTrue(form.is_valid()) 181 | 182 | form.field_error('a', 'test') 183 | self.assertFalse(form.is_valid()) 184 | 185 | def test_form_error(self): 186 | data = {'a': 'a', 'b': 'b', 'c': 'c'} 187 | form = self.TestForm(data) 188 | self.assertTrue(form.is_valid()) 189 | 190 | form.form_error('test') 191 | self.assertFalse(form.is_valid()) 192 | self.assertDictEqual(form.errors, {'__all__': [u'test']}) 193 | 194 | def test_fieldset_error(self): 195 | data = {'a': 'a', 'b': 'b', 'c': 'c'} 196 | form = self.TestForm(data) 197 | self.assertTrue(form.is_valid()) 198 | 199 | self.assertNotIn(form.fieldsets[0].fieldset.error_css_class, form.fieldsets[0].css_classes) 200 | 201 | form.field_error('first', 'test') 202 | self.assertFalse(form.is_valid()) 203 | fieldsets = [fieldset for fieldset in form.fieldsets] 204 | self.assertTrue(fieldsets[0].errors) 205 | self.assertIn(form.fieldsets[0].fieldset.error_css_class, form.fieldsets[0].css_classes) 206 | 207 | def test_fieldset_css_classes(self): 208 | class TestForm(BetterForm): 209 | a = forms.CharField() 210 | b = forms.CharField() 211 | c = forms.CharField() 212 | 213 | class Meta: 214 | fieldsets = ( 215 | ('first', {'fields': ('a', 'b')}), 216 | ('second', {'fields': ('c',), 'css_classes': ['arst', 'tsra']}), 217 | ) 218 | form = TestForm() 219 | self.assertIn('arst', form.fieldsets[1].css_classes) 220 | self.assertIn('tsra', form.fieldsets[1].css_classes) 221 | 222 | def test_fieldset_iteration(self): 223 | form = self.TestForm() 224 | self.assertTupleEqual( 225 | tuple(fieldset.fieldset for fieldset in form), 226 | tuple(fieldset.fieldset for fieldset in form.fieldsets), 227 | ) 228 | 229 | def test_no_fieldsets(self): 230 | class TestForm(BetterForm): 231 | a = forms.CharField() 232 | b = forms.CharField() 233 | c = forms.CharField() 234 | 235 | form = TestForm() 236 | fields_iter = sorted((field.field for field in form), key=id) 237 | fields_values = sorted(form.fields.values(), key=id) 238 | self.assertSequenceEqual(fields_iter, fields_values) 239 | 240 | 241 | class TestBetterModelForm(TestCase): 242 | def setUp(self): 243 | class TestModel(models.Model): 244 | a = models.CharField(max_length=255) 245 | b = models.CharField(max_length=255) 246 | c = models.CharField(max_length=255) 247 | d = models.CharField(max_length=255) 248 | 249 | self.TestModel = TestModel 250 | 251 | def test_basic_fieldsets(self): 252 | class TestModelForm(BetterModelForm): 253 | class Meta: 254 | model = self.TestModel 255 | 256 | fieldsets = ( 257 | ('first', {'fields': ('a',)}), 258 | ('second', {'fields': ('b', 'c')}), 259 | ('third', {'fields': ('d',)}), 260 | ) 261 | form = TestModelForm() 262 | fieldsets = [fieldset for fieldset in form.fieldsets] 263 | self.assertEqual(fieldsets[0].name, 'first') 264 | self.assertEqual(fieldsets[1].name, 'second') 265 | self.assertEqual(fieldsets[2].name, 'third') 266 | self.assertTupleEqual(fieldsets[0].fieldset.fields, ('a',)) 267 | self.assertTupleEqual(fieldsets[1].fieldset.fields, ('b', 'c')) 268 | self.assertTupleEqual(fieldsets[2].fieldset.fields, ('d',)) 269 | 270 | def test_fields_meta_attribute(self): 271 | class TestModelForm1(BetterModelForm): 272 | class Meta: 273 | model = self.TestModel 274 | fieldsets = ( 275 | ('first', {'fields': ('a',)}), 276 | ('second', {'fields': ('b', 'c')}), 277 | ('third', {'fields': ('d',)}), 278 | ) 279 | self.assertTrue(hasattr(TestModelForm1.Meta, 'fields')) 280 | self.assertTupleEqual(TestModelForm1.Meta.fields, ('a', 'b', 'c', 'd')) 281 | 282 | class TestModelForm2(BetterModelForm): 283 | class Meta: 284 | model = self.TestModel 285 | fieldsets = ( 286 | ('first', {'fields': ('a',)}), 287 | ('second', {'fields': ('b', 'c')}), 288 | ('third', {'fields': ('d',)}), 289 | ) 290 | fields = ('a', 'b', 'd') 291 | 292 | self.assertTrue(hasattr(TestModelForm2.Meta, 'fields')) 293 | self.assertTupleEqual(TestModelForm2.Meta.fields, ('a', 'b', 'd')) 294 | 295 | class TestModelForm3(TestModelForm2): 296 | pass 297 | 298 | self.assertTrue(hasattr(TestModelForm3.Meta, 'fields')) 299 | self.assertTupleEqual(TestModelForm3.Meta.fields, ('a', 'b', 'd')) 300 | 301 | class TestModelForm4(TestModelForm2): 302 | class Meta(TestModelForm2.Meta): 303 | fieldsets = ( 304 | ('first', {'fields': ('a', 'c')}), 305 | ('third', {'fields': ('d',)}), 306 | ) 307 | 308 | self.assertTrue(hasattr(TestModelForm4.Meta, 'fields')) 309 | self.assertTupleEqual(TestModelForm4.Meta.fields, ('a', 'c', 'd')) 310 | 311 | 312 | class TestFormRendering(TestCase): 313 | def setUp(self): 314 | class TestForm(BetterForm): 315 | # Set the label_suffix to an empty string for consistent results 316 | # across Django 1.5 and 1.6. 317 | label_suffix = '' 318 | 319 | a = forms.CharField() 320 | b = forms.CharField() 321 | c = forms.CharField() 322 | 323 | class Meta: 324 | fieldsets = ( 325 | ('first', {'fields': ('a', 'b')}), 326 | ('second', {'fields': ('c',)}), 327 | ) 328 | self.TestForm = TestForm 329 | 330 | def test_non_fieldset_form_rendering(self): 331 | class TestForm(BetterForm): 332 | # Set the label_suffix to an empty string for consistent results 333 | # across Django 1.5 and 1.6. 334 | label_suffix = '' 335 | 336 | a = forms.CharField() 337 | b = forms.CharField(required=False) 338 | c = forms.CharField() 339 | 340 | form = TestForm() 341 | env = { 342 | 'form': form, 343 | 'no_head': True, 344 | 'fieldset_template_name': 'betterforms/fieldset_as_div.html', 345 | 'field_template_name': 'betterforms/field_as_div.html', 346 | } 347 | test = """ 348 |
349 | 350 | 351 |
352 |
353 | 354 | 355 |
356 |
357 | 358 | 359 |
360 | """ 361 | 362 | self.assertHTMLEqual( 363 | render_to_string('betterforms/form_as_fieldsets.html', env), 364 | test, 365 | ) 366 | form.field_error('a', 'this is an error message') 367 | test = """ 368 |
369 | 370 | 371 |
  • this is an error message
372 |
373 |
374 | 375 | 376 |
377 |
378 | 379 | 380 |
381 | """ 382 | 383 | self.assertHTMLEqual( 384 | render_to_string('betterforms/form_as_fieldsets.html', env), 385 | test, 386 | ) 387 | 388 | def test_include_tag_rendering(self): 389 | form = self.TestForm() 390 | env = { 391 | 'form': form, 392 | 'no_head': True, 393 | 'fieldset_template_name': 'betterforms/fieldset_as_div.html', 394 | 'field_template_name': 'betterforms/field_as_div.html', 395 | } 396 | test = """ 397 |
398 |
399 | 400 | 401 |
402 |
403 | 404 | 405 |
406 |
407 |
408 |
409 | 410 | 411 |
412 |
413 | """ 414 | 415 | self.assertHTMLEqual( 416 | render_to_string('betterforms/form_as_fieldsets.html', env), 417 | test, 418 | ) 419 | form.field_error('a', 'this is an error message') 420 | test = """ 421 |
422 |
423 | 424 | 425 |
  • this is an error message
426 |
427 |
428 | 429 | 430 |
431 |
432 |
433 |
434 | 435 | 436 |
437 |
438 | """ 439 | 440 | self.assertHTMLEqual( 441 | render_to_string('betterforms/form_as_fieldsets.html', env), 442 | test, 443 | ) 444 | 445 | def test_fields_django_form_required(self): 446 | class TestForm(forms.Form): 447 | a = forms.CharField(label='A:') 448 | b = forms.CharField(label='B:', required=False) 449 | 450 | form = TestForm() 451 | 452 | env = { 453 | 'form': form, 454 | 'no_head': True, 455 | 'fieldset_template_name': 'betterforms/fieldset_as_div.html', 456 | 'field_template_name': 'betterforms/field_as_div.html', 457 | } 458 | test = """ 459 |
460 | 461 | 462 |
463 |
464 | 465 | 466 |
467 | """ 468 | 469 | self.assertHTMLEqual( 470 | render_to_string('betterforms/form_as_fieldsets.html', env), 471 | test, 472 | ) 473 | 474 | @unittest.expectedFailure 475 | def test_form_to_str(self): 476 | # TODO: how do we test this 477 | assert False 478 | 479 | @unittest.expectedFailure 480 | def test_form_as_table(self): 481 | form = self.TestForm() 482 | form.as_table() 483 | 484 | @unittest.expectedFailure 485 | def test_form_as_ul(self): 486 | form = self.TestForm() 487 | form.as_ul() 488 | 489 | def test_form_as_p(self): 490 | form = self.TestForm() 491 | test = """ 492 |
493 |

494 | 495 | 496 |

497 |

498 | 499 | 500 |

501 |
502 |
503 |

504 | 505 | 506 |

507 |
508 | """ 509 | 510 | self.assertHTMLEqual( 511 | form.as_p(), 512 | test, 513 | ) 514 | 515 | form.field_error('a', 'this is an error') 516 | test = """ 517 |
518 |

519 |

  • this is an error
520 | 521 | 522 |

523 |

524 | 525 | 526 |

527 |
528 |
529 |

530 | 531 | 532 |

533 |
534 | """ 535 | self.maxDiff=None 536 | 537 | self.assertHTMLEqual( 538 | form.as_p(), 539 | test, 540 | ) 541 | 542 | def test_fieldset_legend(self): 543 | class TestForm(BetterForm): 544 | a = forms.CharField() 545 | b = forms.CharField() 546 | c = forms.CharField() 547 | 548 | label_suffix = '' 549 | 550 | class Meta: 551 | fieldsets = ( 552 | Fieldset('first', ('a', 'b'), legend='First Fieldset'), 553 | Fieldset('second', ('c',), legend='Second Fieldset'), 554 | ) 555 | 556 | form = TestForm() 557 | test = """ 558 |
559 | First Fieldset 560 |

561 | 562 | 563 |

564 |

565 | 566 | 567 |

568 |
569 |
570 | Second Fieldset 571 |

572 | 573 | 574 |

575 |
576 | """ 577 | 578 | self.assertHTMLEqual( 579 | form.as_p(), 580 | test, 581 | ) 582 | 583 | def test_css_classes_when_form_has_prefix(self): 584 | class TestForm(BetterForm): 585 | name = forms.CharField() 586 | label_suffix = '' 587 | 588 | form = TestForm(prefix="prefix") 589 | env = {'form': form, 'no_head': True} 590 | test = """ 591 |
592 | 593 | 594 |
595 | """ 596 | 597 | self.assertHTMLEqual( 598 | render_to_string('betterforms/form_as_fieldsets.html', env), 599 | test, 600 | ) 601 | 602 | 603 | class ChangeListModel(models.Model): 604 | field_a = models.CharField(max_length=255) 605 | field_b = models.CharField(max_length=255) 606 | field_c = models.TextField(max_length=255) 607 | 608 | 609 | class TestChangleListQuerySetAPI(TestCase): 610 | def setUp(self): 611 | class TestChangeListForm(BaseChangeListForm): 612 | model = ChangeListModel 613 | foo = forms.CharField() 614 | self.TestChangeListForm = TestChangeListForm 615 | 616 | for i in range(5): 617 | ChangeListModel.objects.create(field_a=str(i)) 618 | 619 | def test_with_model_declared(self): 620 | form = self.TestChangeListForm({}) 621 | 622 | # base_queryset should default to Model.objects.all() 623 | self.assertTrue(form.base_queryset.count(), 5) 624 | 625 | def test_with_model_declaration_and_provided_queryset(self): 626 | form = self.TestChangeListForm({'foo': 'arst'}, queryset=ChangeListModel.objects.exclude(field_a='0').exclude(field_a='1')) 627 | 628 | self.assertTrue(form.base_queryset.count(), 3) 629 | form.full_clean() 630 | self.assertTrue(form.get_queryset().count(), 3) 631 | 632 | def test_missing_model_and_queryset(self): 633 | class TestChangeListForm(BaseChangeListForm): 634 | pass 635 | 636 | with self.assertRaises(AttributeError): 637 | TestChangeListForm() 638 | 639 | 640 | class TestSearchFormAPI(TestCase): 641 | def setUp(self): 642 | ChangeListModel.objects.create(field_a='foo', field_b='bar', field_c='baz') 643 | ChangeListModel.objects.create(field_a='bar', field_b='baz') 644 | ChangeListModel.objects.create(field_a='baz') 645 | 646 | def test_requires_search_fields(self): 647 | class TheSearchForm(SearchForm): 648 | model = ChangeListModel 649 | 650 | with self.assertRaises(ImproperlyConfigured): 651 | TheSearchForm({}) 652 | 653 | def test_passing_in_search_fields(self): 654 | class TheSearchForm(SearchForm): 655 | model = ChangeListModel 656 | 657 | form = TheSearchForm({}, search_fields=('field_a',)) 658 | self.assertEqual(form.SEARCH_FIELDS, ('field_a',)) 659 | 660 | form = TheSearchForm({}, search_fields=('field_a', 'field_b')) 661 | self.assertEqual(form.SEARCH_FIELDS, ('field_a', 'field_b')) 662 | 663 | def test_setting_search_fields_on_class(self): 664 | class TheSearchForm(SearchForm): 665 | SEARCH_FIELDS = ('field_a', 'field_b', 'field_c') 666 | model = ChangeListModel 667 | 668 | form = TheSearchForm({}) 669 | self.assertEqual(form.SEARCH_FIELDS, ('field_a', 'field_b', 'field_c')) 670 | 671 | def test_overriding_search_fields_set_on_class(self): 672 | class TheSearchForm(SearchForm): 673 | SEARCH_FIELDS = ('field_a', 'field_b', 'field_c') 674 | model = ChangeListModel 675 | 676 | form = TheSearchForm({}, search_fields=('field_a', 'field_c')) 677 | self.assertEqual(form.SEARCH_FIELDS, ('field_a', 'field_c')) 678 | 679 | def test_searching(self): 680 | class TheSearchForm(SearchForm): 681 | SEARCH_FIELDS = ('field_a', 'field_b', 'field_c') 682 | model = ChangeListModel 683 | 684 | f = TheSearchForm({'q': 'foo'}) 685 | f.full_clean() 686 | self.assertEqual(f.get_queryset().count(), 1) 687 | 688 | f = TheSearchForm({'q': 'bar'}) 689 | f.full_clean() 690 | self.assertEqual(f.get_queryset().count(), 2) 691 | 692 | f = TheSearchForm({'q': 'baz'}) 693 | f.full_clean() 694 | self.assertEqual(f.get_queryset().count(), 3) 695 | 696 | def test_searching_over_limited_fields(self): 697 | class TheSearchForm(SearchForm): 698 | SEARCH_FIELDS = ('field_a', 'field_c') 699 | model = ChangeListModel 700 | 701 | f = TheSearchForm({'q': 'foo'}) 702 | f.full_clean() 703 | self.assertEqual(f.get_queryset().count(), 1) 704 | 705 | f = TheSearchForm({'q': 'bar'}) 706 | f.full_clean() 707 | self.assertEqual(f.get_queryset().count(), 1) 708 | 709 | f = TheSearchForm({'q': 'baz'}) 710 | f.full_clean() 711 | self.assertEqual(f.get_queryset().count(), 2) 712 | 713 | def test_case_insensitive(self): 714 | class TheSearchForm(SearchForm): 715 | SEARCH_FIELDS = ('field_a',) 716 | model = ChangeListModel 717 | 718 | self.assertFalse(TheSearchForm.CASE_SENSITIVE) 719 | 720 | upper_cased = ChangeListModel.objects.create(field_a='TeSt') 721 | lower_cased = ChangeListModel.objects.create(field_a='test') 722 | 723 | form = TheSearchForm({'q': 'Test'}) 724 | form.full_clean() 725 | 726 | self.assertIn(upper_cased, form.get_queryset()) 727 | self.assertIn(lower_cased, form.get_queryset()) 728 | 729 | @unittest.skipIf(settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3', 'Case Sensitive __contains queries are not supported on sqlite.') 730 | def test_case_sensitive(self): 731 | # TODO: make this test run on travis with postgres/mysql to be sure 732 | # this functionality actually works. 733 | class TheSearchForm(SearchForm): 734 | SEARCH_FIELDS = ('field_a', 'field_c') 735 | CASE_SENSITIVE = True 736 | model = ChangeListModel 737 | 738 | self.assertTrue(TheSearchForm.CASE_SENSITIVE) 739 | 740 | upper_cased = ChangeListModel.objects.create(field_a='TeSt') 741 | lower_cased = ChangeListModel.objects.create(field_a='test') 742 | 743 | form = TheSearchForm({'q': 'TeSt'}) 744 | form.full_clean() 745 | 746 | self.assertIn(upper_cased, form.get_queryset()) 747 | self.assertNotIn(lower_cased, form.get_queryset()) 748 | 749 | 750 | class TestHeaderAPI(TestCase): 751 | def test_header_bare_declaration(self): 752 | header = Header('field_a') 753 | 754 | self.assertTrue(header.is_sortable) 755 | self.assertEqual(header.name, 'field_a') 756 | self.assertEqual(header.column_name, 'field_a') 757 | self.assertEqual(header.label, 'Field a') 758 | 759 | def test_header_with_label_declaration(self): 760 | header = Header('field_a', 'Test Label') 761 | 762 | self.assertEqual(header.label, 'Test Label') 763 | 764 | def test_header_with_column_declared(self): 765 | header = Header('not_a_column', column_name='field_a') 766 | 767 | self.assertEqual(header.name, 'not_a_column') 768 | self.assertEqual(header.column_name, 'field_a') 769 | 770 | def test_non_sortable_header(self): 771 | header = Header('field_a', is_sortable=False) 772 | 773 | self.assertFalse(header.is_sortable) 774 | 775 | 776 | class TestHeaderSetAPI(TestCase): 777 | def test_header_names_must_be_unique(self): 778 | HEADERS = ( 779 | Header('field_a'), 780 | Header('field_a'), 781 | ) 782 | with self.assertRaises(ImproperlyConfigured): 783 | HeaderSet(None, HEADERS) 784 | 785 | def test_header_set_declared_as_header_classes(self): 786 | HEADERS = ( 787 | Header('field_a'), 788 | Header('field_b', 'Test Label'), 789 | Header('test_name', 'Test Name', column_name='field_c'), 790 | Header('created_at', is_sortable=False), 791 | ) 792 | self.do_header_set_assertions(HEADERS) 793 | 794 | def test_header_set_declared_as_args(self): 795 | HEADERS = ( 796 | ('field_a',), 797 | ('field_b', 'Test Label'), 798 | ('test_name', 'Test Name', 'field_c'), 799 | ('created_at', None, None, False), 800 | ) 801 | self.do_header_set_assertions(HEADERS) 802 | 803 | def test_header_set_declared_as_name_and_kwargs(self): 804 | HEADERS = ( 805 | ('field_a', {}), 806 | ('field_b', {'label': 'Test Label'}), 807 | ('test_name', {'label': 'Test Name', 'column_name': 'field_c'}), 808 | ('created_at', {'is_sortable': False}), 809 | ) 810 | self.do_header_set_assertions(HEADERS) 811 | 812 | def test_header_set_mixed_declaration_styles(self): 813 | HEADERS = ( 814 | 'field_a', 815 | ('field_b', 'Test Label'), 816 | ('test_name', {'label': 'Test Name', 'column_name': 'field_c'}), 817 | Header('created_at', is_sortable=False), 818 | ) 819 | self.do_header_set_assertions(HEADERS) 820 | 821 | def test_bad_header_declaration(self): 822 | HEADERS = ( 823 | {'bad_declaration': 'test'}, 824 | ('field_b', 'Test Label'), 825 | ('test_name', {'label': 'Test Name', 'column_name': 'field_c'}), 826 | Header('created_at', is_sortable=False), 827 | ) 828 | with self.assertRaises(ImproperlyConfigured): 829 | self.do_header_set_assertions(HEADERS) 830 | 831 | def do_header_set_assertions(self, HEADERS): 832 | header_set = HeaderSet(None, HEADERS) 833 | 834 | self.assertTrue(len(header_set), 4) 835 | self.assertSequenceEqual( 836 | [header.name for header in header_set.headers.values()], 837 | ('field_a', 'field_b', 'test_name', 'created_at'), 838 | ) 839 | self.assertSequenceEqual( 840 | [header.label for header in header_set.headers.values()], 841 | ('Field a', 'Test Label', 'Test Name', 'Created at'), 842 | ) 843 | self.assertSequenceEqual( 844 | [header.column_name for header in header_set.headers.values()], 845 | ('field_a', 'field_b', 'field_c', None), 846 | ) 847 | self.assertSequenceEqual( 848 | [header.is_sortable for header in header_set.headers.values()], 849 | (True, True, True, False), 850 | ) 851 | 852 | def test_iteration_yields_bound_headers(self): 853 | HEADERS = ( 854 | Header('field_a'), 855 | Header('field_b', 'Test Label'), 856 | Header('test_name', 'Test Name', column_name='field_c'), 857 | Header('created_at', is_sortable=False), 858 | ) 859 | form = mock.NonCallableMagicMock(forms.Form) 860 | form.prefix = None 861 | header_set = HeaderSet(form, HEADERS) 862 | 863 | self.assertTrue(all(( 864 | isinstance(header, BoundHeader) for header in header_set 865 | ))) 866 | 867 | def test_index_and_key_lookups(self): 868 | HEADERS = ( 869 | Header('field_a'), 870 | Header('field_b', 'Test Label'), 871 | Header('test_name', 'Test Name', column_name='field_c'), 872 | Header('created_at', is_sortable=False), 873 | ) 874 | form = mock.NonCallableMagicMock(forms.Form) 875 | form.prefix = None 876 | header_set = HeaderSet(form, HEADERS) 877 | 878 | self.assertIsInstance(header_set[0], BoundHeader) 879 | self.assertEqual(header_set[0].header, HEADERS[0]) 880 | 881 | self.assertIsInstance(header_set['field_b'], BoundHeader) 882 | self.assertEqual(header_set['field_b'].header, HEADERS[1]) 883 | 884 | 885 | class TestBoundHeaderAPI(TestCase): 886 | def setUp(self): 887 | self.HEADERS = ( 888 | Header('test_name', 'Test Label', 'column_name', is_sortable=True), 889 | ) 890 | self.form = mock.NonCallableMagicMock(forms.Form) 891 | self.form.prefix = None 892 | self.form.HEADERS = self.HEADERS 893 | 894 | def test_bound_header_pass_through_properties(self): 895 | header_set = HeaderSet(self.form, self.HEADERS) 896 | 897 | self.assertEqual(header_set[0].header, self.HEADERS[0]) 898 | self.assertEqual(header_set[0].name, self.HEADERS[0].name) 899 | self.assertEqual(header_set[0].label, self.HEADERS[0].label) 900 | self.assertEqual(header_set[0].column_name, self.HEADERS[0].column_name) 901 | self.assertEqual(header_set[0].is_sortable, self.HEADERS[0].is_sortable) 902 | 903 | def test_sort_parameter_no_prefix(self): 904 | self.assertIsNone(self.form.prefix) 905 | header = HeaderSet(self.form, self.HEADERS)[0] 906 | 907 | self.assertEqual(header.param, 'sorts') 908 | 909 | def test_sort_parameter_with_prefix(self): 910 | self.form.prefix = 'test' 911 | header = HeaderSet(self.form, self.HEADERS)[0] 912 | 913 | self.assertEqual(header.param, 'test-sorts') 914 | 915 | def test_is_active_property_while_not_active(self): 916 | header = HeaderSet(self.form, self.HEADERS)[0] 917 | 918 | self.assertFalse(header.is_active) 919 | self.assertFalse(header.is_ascending) 920 | self.assertFalse(header.is_descending) 921 | 922 | def test_is_active_property_while_active_and_ascending(self): 923 | self.form.data = {'sorts': '1'} 924 | self.form.cleaned_data = {'sorts': [1]} 925 | header = HeaderSet(self.form, self.HEADERS)[0] 926 | 927 | self.assertTrue(header.is_active) 928 | self.assertTrue(header.is_ascending) 929 | self.assertFalse(header.is_descending) 930 | 931 | def test_is_active_property_while_active_and_descending(self): 932 | self.form.data = {'sorts': '-1'} 933 | self.form.cleaned_data = {'sorts': [-1]} 934 | header = HeaderSet(self.form, self.HEADERS)[0] 935 | 936 | self.assertTrue(header.is_active) 937 | self.assertFalse(header.is_ascending) 938 | self.assertTrue(header.is_descending) 939 | 940 | def test_add_to_sorts_with_no_sorts(self): 941 | HEADERS = ( 942 | Header('field_a'), 943 | Header('field_b'), 944 | Header('field_c'), 945 | ) 946 | self.form.data = {} 947 | self.form.cleaned_data = {'sorts': []} 948 | self.form.HEADERS = HEADERS 949 | header_set = HeaderSet(self.form, HEADERS) 950 | self.assertEqual(header_set['field_a'].add_to_sorts(), [1]) 951 | self.assertEqual(header_set['field_b'].add_to_sorts(), [2]) 952 | self.assertEqual(header_set['field_c'].add_to_sorts(), [3]) 953 | 954 | def test_add_to_sorts_with_active_sorts(self): 955 | HEADERS = ( 956 | Header('field_a'), 957 | Header('field_b'), 958 | Header('field_c'), 959 | ) 960 | self.form.data = {'sorts': '1.-2'} 961 | self.form.cleaned_data = {'sorts': [1, -2]} 962 | self.form.HEADERS = HEADERS 963 | header_set = HeaderSet(self.form, HEADERS) 964 | self.assertEqual(header_set['field_a'].add_to_sorts(), [-1, -2]) 965 | self.assertEqual(header_set['field_b'].add_to_sorts(), [2, 1]) 966 | self.assertEqual(header_set['field_c'].add_to_sorts(), [3, 1, -2]) 967 | 968 | def test_sort_priority_display(self): 969 | HEADERS = ( 970 | Header('field_a'), 971 | Header('field_b'), 972 | Header('field_c'), 973 | ) 974 | self.form.data = {'sorts': '-2.1'} 975 | self.form.cleaned_data = {'sorts': [-2, 1]} 976 | self.form.HEADERS = HEADERS 977 | header_set = HeaderSet(self.form, HEADERS) 978 | self.assertEqual(header_set['field_a'].priority, 2) 979 | self.assertEqual(header_set['field_b'].priority, 1) 980 | self.assertEqual(header_set['field_c'].priority, None) 981 | 982 | def test_css_classes(self): 983 | HEADERS = ( 984 | Header('field_a'), 985 | Header('field_b'), 986 | Header('field_c'), 987 | ) 988 | self.form.data = {'sorts': '1.-2'} 989 | self.form.cleaned_data = {'sorts': [1, -2]} 990 | self.form.HEADERS = HEADERS 991 | header_set = HeaderSet(self.form, HEADERS) 992 | self.assertEqual(header_set['field_a'].css_classes, 'active ascending') 993 | self.assertEqual(header_set['field_b'].css_classes, 'active descending') 994 | self.assertEqual(header_set['field_c'].css_classes, '') 995 | 996 | def test_bound_header_querystring_properties(self): 997 | HEADERS = ( 998 | Header('field_a'), 999 | Header('field_b'), 1000 | Header('field_c'), 1001 | ) 1002 | self.form.data = {'sorts': '1.-2'} 1003 | self.form.cleaned_data = {'sorts': [1, -2]} 1004 | self.form.HEADERS = HEADERS 1005 | header_set = HeaderSet(self.form, HEADERS) 1006 | 1007 | self.assertEqual(header_set['field_a'].querystring, 'sorts=-1.-2') 1008 | self.assertEqual(header_set['field_a'].singular_querystring, 'sorts=-1') 1009 | self.assertEqual(header_set['field_a'].remove_querystring, 'sorts=-2') 1010 | 1011 | self.assertEqual(header_set['field_b'].querystring, 'sorts=2.1') 1012 | self.assertEqual(header_set['field_b'].singular_querystring, 'sorts=2') 1013 | self.assertEqual(header_set['field_b'].remove_querystring, 'sorts=1') 1014 | 1015 | self.assertEqual(header_set['field_c'].querystring, 'sorts=3.1.-2') 1016 | self.assertEqual(header_set['field_c'].singular_querystring, 'sorts=3') 1017 | self.assertEqual(header_set['field_c'].remove_querystring, 'sorts=1.-2') 1018 | 1019 | def assertQueryStringEqual(self, a, b, *args, **kwargs): 1020 | """ 1021 | We need this because QueryDicts are dicts and key-ordering is not 1022 | guaranteed on Python3. So we just convert query_strings back into 1023 | QueryDicts to compare them. 1024 | """ 1025 | self.assertEqual(QueryDict(a), QueryDict(b), *args, **kwargs) 1026 | 1027 | def test_bound_header_querystring_with_querydict(self): 1028 | HEADERS = ( 1029 | Header('field_a'), 1030 | ) 1031 | self.form.data = QueryDict('field=value') 1032 | self.form.cleaned_data = {'field': 'value'} 1033 | self.form.HEADERS = HEADERS 1034 | header_set = HeaderSet(self.form, HEADERS) 1035 | 1036 | self.assertQueryStringEqual(header_set['field_a'].querystring, 'field=value&sorts=1') 1037 | self.assertQueryStringEqual(header_set['field_a'].singular_querystring, 'field=value&sorts=1') 1038 | self.assertQueryStringEqual(header_set['field_a'].remove_querystring, 'field=value&sorts=') 1039 | 1040 | def test_bound_header_querystring_with_querydict_overwrites_instead_of_appending(self): 1041 | HEADERS = ( 1042 | Header('field_a'), 1043 | ) 1044 | self.form.data = QueryDict('field=value&sorts=1') 1045 | self.form.cleaned_data = {'field': 'value', 'sorts': [1]} 1046 | self.form.HEADERS = HEADERS 1047 | header_set = HeaderSet(self.form, HEADERS) 1048 | 1049 | # It used to output 'field=value&sorts=1&sorts=-1' 1050 | self.assertQueryStringEqual(header_set['field_a'].querystring, 'field=value&sorts=-1') 1051 | self.assertQueryStringEqual(header_set['field_a'].singular_querystring, 'field=value&sorts=-1') 1052 | self.assertQueryStringEqual(header_set['field_a'].remove_querystring, 'field=value&sorts=') 1053 | 1054 | 1055 | class TestSortFormAPI(TestCase): 1056 | def setUp(self): 1057 | self.abc = ChangeListModel.objects.create(field_a='a', field_b='b', field_c='c') 1058 | self.cab = ChangeListModel.objects.create(field_a='c', field_b='a', field_c='b') 1059 | self.bca = ChangeListModel.objects.create(field_a='b', field_b='c', field_c='a') 1060 | 1061 | class TestSortForm(SortForm): 1062 | model = ChangeListModel 1063 | HEADERS = ( 1064 | Header('field_a'), 1065 | Header('field_b'), 1066 | Header('named_header', column_name='field_c'), 1067 | Header('not_sortable', is_sortable=False), 1068 | ) 1069 | self.TestSortForm = TestSortForm 1070 | 1071 | def test_valid_with_no_sorts(self): 1072 | form = self.TestSortForm({}) 1073 | self.assertTrue(form.is_valid()) 1074 | self.assertEqual(form.get_queryset().count(), 3) 1075 | 1076 | def test_sort_field_cleaning(self): 1077 | self.assertTrue(self.TestSortForm({'sorts': '1.2.3'}).is_valid()) 1078 | self.assertTrue(self.TestSortForm({'sorts': '2.3.1'}).is_valid()) 1079 | self.assertTrue(self.TestSortForm({'sorts': '3.1.2'}).is_valid()) 1080 | 1081 | # Unsortable Header 1082 | unsortable = self.TestSortForm({'sorts': '1.2.3.4'}) 1083 | self.assertFalse(unsortable.is_valid()) 1084 | self.assertIn('sorts', unsortable.errors) 1085 | self.assertIn(self.TestSortForm.error_messages['unsortable_header'], unsortable.errors['sorts']) 1086 | 1087 | # Unknown Header 1088 | unknown = self.TestSortForm({'sorts': '1.2.3.5'}) 1089 | self.assertFalse(unknown.is_valid()) 1090 | self.assertIn('sorts', unsortable.errors) 1091 | self.assertIn(self.TestSortForm.error_messages['unknown_header'], unsortable.errors['sorts']) 1092 | 1093 | # Invalid Header 1094 | unknown = self.TestSortForm({'sorts': '1.2.X'}) 1095 | self.assertFalse(unknown.is_valid()) 1096 | self.assertIn('sorts', unsortable.errors) 1097 | self.assertIn(self.TestSortForm.error_messages['unknown_header'], unsortable.errors['sorts']) 1098 | 1099 | def test_single_field_sorting(self): 1100 | f = self.TestSortForm({'sorts': '1'}) 1101 | f.full_clean() 1102 | self.assertSequenceEqual( 1103 | f.get_queryset(), 1104 | (self.abc, self.bca, self.cab), 1105 | ) 1106 | 1107 | f = self.TestSortForm({'sorts': '-1'}) 1108 | f.full_clean() 1109 | self.assertSequenceEqual( 1110 | f.get_queryset(), 1111 | (self.cab, self.bca, self.abc), 1112 | ) 1113 | 1114 | f = self.TestSortForm({'sorts': '2'}) 1115 | f.full_clean() 1116 | self.assertSequenceEqual( 1117 | f.get_queryset(), 1118 | (self.cab, self.abc, self.bca), 1119 | ) 1120 | 1121 | f = self.TestSortForm({'sorts': '-2'}) 1122 | f.full_clean() 1123 | self.assertSequenceEqual( 1124 | f.get_queryset(), 1125 | (self.bca, self.abc, self.cab), 1126 | ) 1127 | 1128 | f = self.TestSortForm({'sorts': '3'}) 1129 | f.full_clean() 1130 | self.assertSequenceEqual( 1131 | f.get_queryset(), 1132 | (self.bca, self.cab, self.abc), 1133 | ) 1134 | 1135 | f = self.TestSortForm({'sorts': '-3'}) 1136 | f.full_clean() 1137 | self.assertSequenceEqual( 1138 | f.get_queryset(), 1139 | (self.abc, self.cab, self.bca), 1140 | ) 1141 | 1142 | def test_multi_field_sorting(self): 1143 | self.aac = ChangeListModel.objects.create(field_a='a', field_b='a', field_c='c') 1144 | 1145 | f = self.TestSortForm({'sorts': '1.2'}) 1146 | f.full_clean() 1147 | self.assertSequenceEqual( 1148 | f.get_queryset(), 1149 | (self.aac, self.abc, self.bca, self.cab), 1150 | ) 1151 | 1152 | f = self.TestSortForm({'sorts': '1.-2'}) 1153 | f.full_clean() 1154 | self.assertSequenceEqual( 1155 | f.get_queryset(), 1156 | (self.abc, self.aac, self.bca, self.cab), 1157 | ) 1158 | 1159 | def test_order_by_override(self): 1160 | self.aac = ChangeListModel.objects.create(field_a='a', field_b='a', field_c='c') 1161 | self.aab = ChangeListModel.objects.create(field_a='a', field_b='a', field_c='b') 1162 | 1163 | class OverriddenOrderForm(self.TestSortForm): 1164 | def get_order_by(self): 1165 | order_by = super().get_order_by() 1166 | return ['field_a'] + order_by 1167 | 1168 | f = OverriddenOrderForm({'sorts': '-3.2'}) 1169 | f.full_clean() 1170 | self.assertSequenceEqual( 1171 | f.get_queryset(), 1172 | (self.aac, self.abc, self.aab, self.bca, self.cab), 1173 | ) 1174 | 1175 | f = OverriddenOrderForm({'sorts': '3.2'}) 1176 | f.full_clean() 1177 | self.assertSequenceEqual( 1178 | f.get_queryset(), 1179 | (self.aab, self.aac, self.abc, self.bca, self.cab), 1180 | ) 1181 | -------------------------------------------------------------------------------- /betterforms/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import ListView, FormView 2 | 3 | 4 | class BrowseView(ListView, FormView): 5 | """ 6 | Class Based view for working with changelists. 7 | """ 8 | def post(self, *args, **kwargs): 9 | return self.http_method_not_allowed(*args, **kwargs) 10 | 11 | def get_form_kwargs(self): 12 | kwargs = { 13 | 'initial': self.get_initial(), 14 | 'queryset': self.object_list, 15 | 'data': self.request.GET, 16 | 'files': self.request.FILES, 17 | } 18 | return kwargs 19 | 20 | def get_context_data(self, **kwargs): 21 | form_class = self.get_form_class() 22 | form = self.get_form(form_class) 23 | kwargs['form'] = form 24 | if form.is_valid(): 25 | kwargs['object_list'] = form.get_queryset() 26 | else: 27 | kwargs['object_list'] = form.base_queryset.none() 28 | kwargs = super().get_context_data(**kwargs) 29 | return kwargs 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-betterforms.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-betterforms.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-betterforms" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-betterforms" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_ext/djangodocs.py: -------------------------------------------------------------------------------- 1 | def setup(app): 2 | app.add_crossref_type( 3 | directivename="setting", 4 | rolename="setting", 5 | indextemplate="pair: %s; setting", 6 | ) 7 | -------------------------------------------------------------------------------- /docs/_themes/kr/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {% if theme_touch_icon %} 5 | 6 | {% endif %} 7 | 9 | 10 | {% endblock %} 11 | {%- block relbar2 %}{% endblock %} 12 | {%- block footer %} 13 | 16 | 17 | Fork me on GitHub 18 | 19 | {%- endblock %} 20 | -------------------------------------------------------------------------------- /docs/_themes/kr/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /docs/_themes/kr/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. Modifications by Kenneth Reitz. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '940px' %} 10 | {% set sidebar_width = '220px' %} 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro'; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: {{ page_width }}; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 {{ sidebar_width }}; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: {{ sidebar_width }}; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: {{ page_width }}; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0; 95 | margin: -10px 0 0 -20px; 96 | text-align: center; 97 | } 98 | 99 | div.sphinxsidebar h3, 100 | div.sphinxsidebar h4 { 101 | font-family: 'Garamond', 'Georgia', serif; 102 | color: #444; 103 | font-size: 24px; 104 | font-weight: normal; 105 | margin: 0 0 5px 0; 106 | padding: 0; 107 | } 108 | 109 | div.sphinxsidebar h4 { 110 | font-size: 20px; 111 | } 112 | 113 | div.sphinxsidebar h3 a { 114 | color: #444; 115 | } 116 | 117 | div.sphinxsidebar p.logo a, 118 | div.sphinxsidebar h3 a, 119 | div.sphinxsidebar p.logo a:hover, 120 | div.sphinxsidebar h3 a:hover { 121 | border: none; 122 | } 123 | 124 | div.sphinxsidebar p { 125 | color: #555; 126 | margin: 10px 0; 127 | } 128 | 129 | div.sphinxsidebar ul { 130 | margin: 10px 0; 131 | padding: 0; 132 | color: #000; 133 | } 134 | 135 | div.sphinxsidebar input { 136 | border: 1px solid #ccc; 137 | font-family: 'Georgia', serif; 138 | font-size: 1em; 139 | } 140 | 141 | /* -- body styles ----------------------------------------------------------- */ 142 | 143 | a { 144 | color: #004B6B; 145 | text-decoration: underline; 146 | } 147 | 148 | a:hover { 149 | color: #6D4100; 150 | text-decoration: underline; 151 | } 152 | 153 | div.body h1, 154 | div.body h2, 155 | div.body h3, 156 | div.body h4, 157 | div.body h5, 158 | div.body h6 { 159 | font-family: 'Garamond', 'Georgia', serif; 160 | font-weight: normal; 161 | margin: 30px 0px 10px 0px; 162 | padding: 0; 163 | } 164 | 165 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 166 | div.body h2 { font-size: 180%; } 167 | div.body h3 { font-size: 150%; } 168 | div.body h4 { font-size: 130%; } 169 | div.body h5 { font-size: 100%; } 170 | div.body h6 { font-size: 100%; } 171 | 172 | a.headerlink { 173 | color: #ddd; 174 | padding: 0 4px; 175 | text-decoration: none; 176 | } 177 | 178 | a.headerlink:hover { 179 | color: #444; 180 | background: #eaeaea; 181 | } 182 | 183 | div.body p, div.body dd, div.body li { 184 | line-height: 1.4em; 185 | } 186 | 187 | div.admonition { 188 | background: #fafafa; 189 | margin: 20px -30px; 190 | padding: 10px 30px; 191 | border-top: 1px solid #ccc; 192 | border-bottom: 1px solid #ccc; 193 | } 194 | 195 | div.admonition tt.xref, div.admonition a tt { 196 | border-bottom: 1px solid #fafafa; 197 | } 198 | 199 | dd div.admonition { 200 | margin-left: -60px; 201 | padding-left: 60px; 202 | } 203 | 204 | div.admonition p.admonition-title { 205 | font-family: 'Garamond', 'Georgia', serif; 206 | font-weight: normal; 207 | font-size: 24px; 208 | margin: 0 0 10px 0; 209 | padding: 0; 210 | line-height: 1; 211 | } 212 | 213 | div.admonition p.last { 214 | margin-bottom: 0; 215 | } 216 | 217 | div.highlight { 218 | background-color: white; 219 | } 220 | 221 | dt:target, .highlight { 222 | background: #FAF3E8; 223 | } 224 | 225 | div.note { 226 | background-color: #eee; 227 | border: 1px solid #ccc; 228 | } 229 | 230 | div.seealso { 231 | background-color: #ffc; 232 | border: 1px solid #ff6; 233 | } 234 | 235 | div.topic { 236 | background-color: #eee; 237 | } 238 | 239 | p.admonition-title { 240 | display: inline; 241 | } 242 | 243 | p.admonition-title:after { 244 | content: ":"; 245 | } 246 | 247 | pre, tt { 248 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 249 | font-size: 0.9em; 250 | } 251 | 252 | img.screenshot { 253 | } 254 | 255 | tt.descname, tt.descclassname { 256 | font-size: 0.95em; 257 | } 258 | 259 | tt.descname { 260 | padding-right: 0.08em; 261 | } 262 | 263 | img.screenshot { 264 | -moz-box-shadow: 2px 2px 4px #eee; 265 | -webkit-box-shadow: 2px 2px 4px #eee; 266 | box-shadow: 2px 2px 4px #eee; 267 | } 268 | 269 | table.docutils { 270 | border: 1px solid #888; 271 | -moz-box-shadow: 2px 2px 4px #eee; 272 | -webkit-box-shadow: 2px 2px 4px #eee; 273 | box-shadow: 2px 2px 4px #eee; 274 | } 275 | 276 | table.docutils td, table.docutils th { 277 | border: 1px solid #888; 278 | padding: 0.25em 0.7em; 279 | } 280 | 281 | table.field-list, table.footnote { 282 | border: none; 283 | -moz-box-shadow: none; 284 | -webkit-box-shadow: none; 285 | box-shadow: none; 286 | } 287 | 288 | table.footnote { 289 | margin: 15px 0; 290 | width: 100%; 291 | border: 1px solid #eee; 292 | background: #fdfdfd; 293 | font-size: 0.9em; 294 | } 295 | 296 | table.footnote + table.footnote { 297 | margin-top: -15px; 298 | border-top: none; 299 | } 300 | 301 | table.field-list th { 302 | padding: 0 0.8em 0 0; 303 | } 304 | 305 | table.field-list td { 306 | padding: 0; 307 | } 308 | 309 | table.footnote td.label { 310 | width: 0px; 311 | padding: 0.3em 0 0.3em 0.5em; 312 | } 313 | 314 | table.footnote td { 315 | padding: 0.3em 0.5em; 316 | } 317 | 318 | dl { 319 | margin: 0; 320 | padding: 0; 321 | } 322 | 323 | dl dd { 324 | margin-left: 30px; 325 | } 326 | 327 | blockquote { 328 | margin: 0 0 0 30px; 329 | padding: 0; 330 | } 331 | 332 | ul, ol { 333 | margin: 10px 0 10px 30px; 334 | padding: 0; 335 | } 336 | 337 | pre { 338 | background: #eee; 339 | padding: 7px 30px; 340 | margin: 15px -30px; 341 | line-height: 1.3em; 342 | } 343 | 344 | dl pre, blockquote pre, li pre { 345 | margin-left: -60px; 346 | padding-left: 60px; 347 | } 348 | 349 | dl dl pre { 350 | margin-left: -90px; 351 | padding-left: 90px; 352 | } 353 | 354 | tt { 355 | background-color: #ecf0f3; 356 | color: #222; 357 | /* padding: 1px 2px; */ 358 | } 359 | 360 | tt.xref, a tt { 361 | background-color: #FBFBFB; 362 | border-bottom: 1px solid white; 363 | } 364 | 365 | a.reference { 366 | text-decoration: none; 367 | border-bottom: 1px dotted #004B6B; 368 | } 369 | 370 | a.reference:hover { 371 | border-bottom: 1px solid #6D4100; 372 | } 373 | 374 | a.footnote-reference { 375 | text-decoration: none; 376 | font-size: 0.7em; 377 | vertical-align: top; 378 | border-bottom: 1px dotted #004B6B; 379 | } 380 | 381 | a.footnote-reference:hover { 382 | border-bottom: 1px solid #6D4100; 383 | } 384 | 385 | a:hover tt { 386 | background: #EEE; 387 | } 388 | 389 | 390 | @media screen and (max-width: 600px) { 391 | 392 | div.sphinxsidebar { 393 | display: none; 394 | } 395 | 396 | div.document { 397 | width: 100%; 398 | 399 | } 400 | 401 | div.documentwrapper { 402 | margin-left: 0; 403 | margin-top: 0; 404 | margin-right: 0; 405 | margin-bottom: 0; 406 | } 407 | 408 | div.bodywrapper { 409 | margin-top: 0; 410 | margin-right: 0; 411 | margin-bottom: 0; 412 | margin-left: 0; 413 | } 414 | 415 | ul { 416 | margin-left: 0; 417 | } 418 | 419 | .document { 420 | width: auto; 421 | } 422 | 423 | .footer { 424 | width: auto; 425 | } 426 | 427 | .bodywrapper { 428 | margin: 0; 429 | } 430 | 431 | .footer { 432 | width: auto; 433 | } 434 | 435 | .github { 436 | display: none; 437 | } 438 | 439 | } 440 | 441 | /* misc. */ 442 | 443 | .revsys-inline { 444 | display: none!important; 445 | } 446 | -------------------------------------------------------------------------------- /docs/_themes/kr/static/small_flask.css: -------------------------------------------------------------------------------- 1 | /* 2 | * small_flask.css_t 3 | * ~~~~~~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | body { 10 | margin: 0; 11 | padding: 20px 30px; 12 | } 13 | 14 | div.documentwrapper { 15 | float: none; 16 | background: white; 17 | } 18 | 19 | div.sphinxsidebar { 20 | display: block; 21 | float: none; 22 | width: 102.5%; 23 | margin: 50px -30px -20px -30px; 24 | padding: 10px 20px; 25 | background: #333; 26 | color: white; 27 | } 28 | 29 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 30 | div.sphinxsidebar h3 a { 31 | color: white; 32 | } 33 | 34 | div.sphinxsidebar a { 35 | color: #aaa; 36 | } 37 | 38 | div.sphinxsidebar p.logo { 39 | display: none; 40 | } 41 | 42 | div.document { 43 | width: 100%; 44 | margin: 0; 45 | } 46 | 47 | div.related { 48 | display: block; 49 | margin: 0; 50 | padding: 10px 0 20px 0; 51 | } 52 | 53 | div.related ul, 54 | div.related ul li { 55 | margin: 0; 56 | padding: 0; 57 | } 58 | 59 | div.footer { 60 | display: none; 61 | } 62 | 63 | div.bodywrapper { 64 | margin: 0; 65 | } 66 | 67 | div.body { 68 | min-height: 0; 69 | padding: 0; 70 | } 71 | 72 | .rtd_doc_footer { 73 | display: none; 74 | } 75 | 76 | .document { 77 | width: auto; 78 | } 79 | 80 | .footer { 81 | width: auto; 82 | } 83 | 84 | .footer { 85 | width: auto; 86 | } 87 | 88 | .github { 89 | display: none; 90 | } -------------------------------------------------------------------------------- /docs/_themes/kr/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | touch_icon = 8 | -------------------------------------------------------------------------------- /docs/basics.rst: -------------------------------------------------------------------------------- 1 | Forms 2 | ===== 3 | 4 | .. currentmodule:: betterforms.forms 5 | 6 | ``django-betterforms`` provides two new form base classes to use in place of 7 | :class:`django:django.forms.Form`. and :class:`django:django.forms.ModelForm`. 8 | 9 | 10 | .. class:: BetterForm 11 | 12 | Base form class for non-model forms. 13 | 14 | .. class:: BetterModelForm 15 | 16 | Base form class for model forms. 17 | 18 | Errors 19 | ------ 20 | 21 | Adding errors in ``betterforms`` is easy: 22 | 23 | >>> form = BlogEntryForm(request.POST) 24 | >>> form.is_valid() 25 | True 26 | >>> form.field_error('title', 'This title is already taken') 27 | >>> form.is_valid() 28 | False 29 | >>> form.errors 30 | {'title': ['This title is already taken']} 31 | 32 | You can also add global errors: 33 | 34 | >>> form = BlogEntryForm(request.POST) 35 | >>> form.form_error('Not accepting new entries at this time') 36 | >>> form.is_valid() 37 | False 38 | >>> form.errors 39 | {'__all__': ['Not accepting new entries at this time']} 40 | 41 | `form_error` is simply a wrapper around `field_error` that uses the key 42 | `__all__` for the field name. 43 | 44 | Fieldsets 45 | --------- 46 | 47 | One of the most powerful features in ``betterforms`` is the ability to declare 48 | field groupings. Both :class:`BetterForm` and :class:`BetterModelForm` provide 49 | a common interface for working with fieldsets. 50 | 51 | Fieldsets can be declared in any of three formats, or any mix of the three 52 | formats. 53 | 54 | * As Two Tuples 55 | 56 | Similar to :ref:`admin fieldsets `, as a list of 57 | two tuples. The two tuples should be in the format ``(name, 58 | fieldset_options)`` where ``name`` is a string representing the title of the 59 | fieldset and ``fieldset_options`` is a dictionary which will be passed as 60 | ``kwargs`` to the constructor of the fieldset. 61 | 62 | .. code-block:: python 63 | 64 | from betterforms.forms import BetterForm 65 | 66 | class RegistrationForm(BetterForm): 67 | ... 68 | class Meta: 69 | fieldsets = ( 70 | ('info', {'fields': ('username', 'email')}), 71 | ('location', {'fields': ('address', ('city', 'state', 'zip'))}), 72 | ('password', {'password1', 'password2'}), 73 | ) 74 | 75 | * As a list of field names 76 | 77 | Fieldsets can be declared as a list of field names. 78 | 79 | .. code-block:: python 80 | 81 | from betterforms.forms import BetterForm 82 | 83 | class RegistrationForm(BetterForm): 84 | ... 85 | class Meta: 86 | fieldsets = ( 87 | ('username', 'email'), 88 | ('address', ('city', 'state', 'zip')), 89 | ('password1', 'password2'), 90 | ) 91 | 92 | * As instantiated :class:`Fieldset` instances or subclasses of :class:`Fieldset`. 93 | 94 | Fieldsets can be declared as a list of field names. 95 | 96 | .. code-block:: python 97 | 98 | from betterforms.forms import BetterForm, Fieldset 99 | 100 | class RegistrationForm(BetterForm): 101 | ... 102 | class Meta: 103 | fieldsets = ( 104 | Fieldset('info', fields=('username', 'email')), 105 | Fieldset('location', ('address', ('city', 'state', 'zip'))), 106 | Fieldset('password', ('password1', 'password2')), 107 | ) 108 | 109 | All three of these examples will have *appoximately* the same output. All of 110 | these formats can be mixed and matched and nested within each other. And 111 | Unlike django-admin, you may nest fieldsets as deep as you would like. 112 | 113 | A :class:`Fieldset` can also optionally be declared with a legend kwarg, 114 | which will then be made available as a property to the associated 115 | :class:`BoundFieldset`. 116 | 117 | .. code-block:: python 118 | 119 | Fieldset('location', ('address', ('city', 'state', 'zip')), legend='Place of Residence') 120 | 121 | Should you choose to render the form using the betterform templates detailed below, 122 | each fieldset with a legend will be rendered with an added legend tag in the template. 123 | 124 | Rendering 125 | --------- 126 | 127 | To render a form, use the provided template partial. 128 | 129 | .. code-block:: html 130 | 131 |
132 | {% include 'betterforms/form_as_fieldsets.html' %} 133 |
134 | 135 | This partial assumes there is a variable ``form`` available in its context. 136 | This template does the following things. 137 | 138 | * outputs the ``csrf_token``. 139 | * outputs a hidden field named ``next`` if there is a ``next`` value available 140 | in the template context. 141 | * outputs the form media 142 | * loops over ``form.fieldsets``. 143 | * for each fieldsets, renders the fieldset using the template 144 | ``betterforms/fieldset_as_div.html`` 145 | 146 | * for each item in the fieldset, if it is a fieldset, it is rendered using 147 | the same template, and if it is a field, renders it using the field 148 | template. 149 | 150 | * for each field, renders the field using the template 151 | ``betterforms/field_as_div.html`` 152 | 153 | If you want to output the form without the CSRF token (for example on a GET 154 | form), you can do so by passing in the csrf_exempt variable. 155 | 156 | .. code-block:: html 157 | 158 |
159 | {% include 'betterforms/form_as_fieldsets.html' csrf_exempt=True %} 160 |
161 | 162 | If you wish to override the label suffix, ``django-betterforms`` provides a 163 | convenient class attribute on the :class:`BetterForm` and 164 | :class:`BetterModelForm` classes. :: 165 | 166 | 167 | class MyForm(forms.BetterForm): 168 | # ... fields 169 | 170 | label_suffix = '->' 171 | 172 | .. warning:: 173 | 174 | Due to a bug in dealing with the label suffix in Django < 1.6, the 175 | ``label_suffix`` will not appear in any forms rendered using the 176 | betterforms templates. For more information, refer to the `Django bug 177 | #18134`_. 178 | 179 | .. _Django bug #18134: https://code.djangoproject.com/ticket/18134 180 | -------------------------------------------------------------------------------- /docs/changelist.rst: -------------------------------------------------------------------------------- 1 | Changelist Forms 2 | ================ 3 | 4 | .. currentmodule:: betterforms.changelist 5 | 6 | **Changelist Forms** are designed to facilitate easy searching and sorting on 7 | django models, along with providing a framework for building other 8 | functionality that deals with operations on lists of model instances. 9 | 10 | 11 | .. class:: ChangeListForm 12 | 13 | Base form class for all **Changelist** forms. 14 | 15 | * setting the queryset: 16 | 17 | All changelist forms need to *know* what queryset to begin with. You can 18 | do this by either passing a named keyword parameter into the contructor of 19 | the form, or by defining a model attribute on the class. 20 | 21 | 22 | .. class:: SearchForm 23 | 24 | Form class which facilitates searching across a set of specified fields for 25 | a model. This form adds a field to the model ``q`` for the search query. 26 | 27 | .. attribute:: SEARCH_FIELDS 28 | 29 | The list of fields that will be searched against. 30 | 31 | .. attribute:: CASE_SENSITIVE 32 | 33 | Whether the search should be case sensitive. 34 | 35 | 36 | Here is a simple :class:`SearchForm` example for searching across users. 37 | 38 | .. code-block:: python 39 | 40 | # my_app/forms.py 41 | from django.contrib.auth.models import get_user_model 42 | from betterforms.changelist import SearchForm 43 | 44 | class UserSearchForm(SearchForm): 45 | SEARCH_FIELDS = ('username', 'email', 'name') 46 | model = get_user_model() 47 | 48 | # my_app.views.py 49 | from my_app.forms import UserSearchForm 50 | 51 | def user_list_view(request): 52 | form = UserSearchForm(request.GET) 53 | context = {'form': form} 54 | if form.is_valid: 55 | context['queryset'] = form.get_queryset() 56 | return render_to_response(context, ...) 57 | 58 | :class:`SearchForm` checks to see if the query value is present in any of 59 | the fields declared in ``SEARCH_FIELDS`` by or-ing together ``Q`` objects 60 | using ``__contains`` or ``__icontains`` queries on those fields. 61 | 62 | 63 | .. class:: SortForm 64 | 65 | Form which facilitates the sorting instances of a model. This form adds a 66 | hidden field ``sorts`` to the model which is used to dictate the columns 67 | which should be sorted on and in what order. 68 | 69 | 70 | .. attribute:: HEADERS 71 | 72 | The list of :class:`Header` objects for sorting. 73 | 74 | Headers can be declared in multiple ways. 75 | 76 | * As instantiated :class:`Header` objects.: 77 | 78 | .. code-block:: python 79 | 80 | # my_app/forms.py 81 | from betterforms.forms import SortForm, Header 82 | 83 | class UserSortForm(SortForm): 84 | HEADERS = ( 85 | Header('username', ..), 86 | Header('email', ..), 87 | Header('name', ..), 88 | ) 89 | model = User 90 | 91 | * As a string: 92 | 93 | .. code-block:: python 94 | 95 | # my_app/forms.py 96 | from betterforms.forms import SortForm 97 | 98 | class UserSortForm(SortForm): 99 | HEADERS = ( 100 | 'username', 101 | 'email', 102 | 'name', 103 | ) 104 | model = User 105 | 106 | * As an iterable of ``*args`` which will be used to instantiate the 107 | :class:`Header` object.: 108 | 109 | .. code-block:: python 110 | 111 | # my_app/forms.py 112 | from betterforms.forms import SortForm 113 | 114 | class UserSortForm(SortForm): 115 | HEADERS = ( 116 | ('username', ..), 117 | ('email', ..), 118 | ('name', ..), 119 | ) 120 | model = User 121 | 122 | * As a two-tuple of **header name** and ``**kwargs``. The name and 123 | provided ``**kwargs`` will be used to instantiate the :class:`Header` 124 | objects. 125 | 126 | .. code-block:: python 127 | 128 | # my_app/forms.py 129 | from betterforms.forms import SortForm 130 | 131 | class UserSortForm(SortForm): 132 | HEADERS = ( 133 | ('username', {..}), 134 | ('email', {..}), 135 | ('name', {..}), 136 | ) 137 | model = User 138 | 139 | All of these examples are roughly equivilent, resulting in the form 140 | haveing three sortable headers, ``('username', 'email', 'name')``, which 141 | will map to those named fields on the ``User`` model. 142 | 143 | See documentation on the :class:`Header` class for more information on 144 | how sort headers can be configured. 145 | 146 | .. method:: get_order_by 147 | 148 | Returns a list of column names that are used in the ``order_by`` call on 149 | the returned queryset. 150 | 151 | During instantiation, all declared headers on ``form.HEADERS`` are converted 152 | to :class:`Header` objects and are accessible from ``form.headers``. 153 | 154 | .. code-block:: python 155 | 156 | >>> [header for header in form.headers] # Iterate over all headers. 157 | >>> form.headers[2] # Get the header at index-2 158 | >>> form.headers['username'] # Get the header named 'username' 159 | 160 | .. class:: Header(name, label=None, column_name=None, is_sortable=True) 161 | 162 | Headers are the the mechanism through which :class:`SortForm` shines. They 163 | provide querystrings for operations related to sorting by whatever query 164 | parameter that header is tied to, as well as other values that are helpful 165 | during rendering. 166 | 167 | .. attribute:: name 168 | 169 | The name of the header. 170 | 171 | .. attribute:: label 172 | 173 | The human readable name of the header. 174 | 175 | .. attribute:: is_active 176 | 177 | ``Boolean`` as to whether this header is currently being used to sort. 178 | 179 | .. attribute:: is_ascending 180 | 181 | ``Boolean`` as to whether this header is being used to sort the data in 182 | ascending order. 183 | 184 | .. attribute:: is_descending 185 | 186 | ``Boolean`` as to whether this header is being used to sort the data in 187 | descending order. 188 | 189 | .. attribute:: css_classes 190 | 191 | Space separated list of css classes, suitable for output in the template as 192 | an HTML element's css class attribute. 193 | 194 | .. attribute:: priority 195 | 196 | 1-indexed number as to the priority this header is at in the list of sorts. 197 | Returns ``None`` if the header is not active. 198 | 199 | .. attribute:: querystring 200 | 201 | The querystring that will sort by this header as the primary sort, moving 202 | all other active sorts in their current order after this one. Preserves 203 | all other query parameters. 204 | 205 | .. attribute:: remove_querystring 206 | 207 | The querystring that will remove this header from the sorts. Preserves all 208 | other query parameters. 209 | 210 | .. attribute:: singular_querystring 211 | 212 | The querystring that will sort by this header, and deactivate all other 213 | active sorts. Preserves all other query parameters. 214 | 215 | Working With Changelists 216 | ------------------------ 217 | 218 | Outputting sort form headers can be done using a provided template partial 219 | located at ``betterforms/sort_form_header.html`` 220 | 221 | .. code-block:: html 222 | 223 | 224 | {% if header.is_sortable %} 225 | {{ header.label }} 226 | {% if header.is_active %} 227 | {% if header.is_ascending %} 228 | ▾ 229 | {% elif header.is_descending %} 230 | ▴ 231 | {% endif %} 232 | 233 | {{ header.priority }} x 234 | {% endif %} 235 | {% else %} 236 | {{ header.label }} 237 | {% endif %} 238 | 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 |
45 | {% include 'betterforms/form_as_fieldsets.html' %} 46 |
47 | 48 | Which will render the following. 49 | 50 | .. code-block:: html 51 | 52 |
53 |
54 | 55 | 56 |
57 | 61 |
62 |
63 |
64 | 65 | 66 |
67 |
68 |
69 | 70 | 71 |
72 |
73 | 74 | 75 |
76 |
77 | 78 | 79 |
80 |
81 |
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 | --------------------------------------------------------------------------------