├── extra_views
├── models.py
├── contrib
│ ├── __init__.py
│ └── mixins.py
├── __init__.py
├── generic.py
├── advanced.py
├── dates.py
└── formsets.py
├── extra_views_tests
├── __init__.py
├── templates
│ ├── 404.html
│ └── extra_views
│ │ ├── success.html
│ │ ├── event_calendar_month.html
│ │ ├── item_list.html
│ │ ├── item_formset.html
│ │ ├── paged_formset.html
│ │ ├── inline_formset.html
│ │ ├── address_formset.html
│ │ ├── order_and_items.html
│ │ ├── formsets_multiview.html
│ │ ├── orderaddress_multiview.html
│ │ ├── orderitems_multiview.html
│ │ └── sortable_item_list.html
├── formsets.py
├── forms.py
├── models.py
├── urls.py
├── views.py
└── tests.py
├── setup.cfg
├── docs
├── changelog.rst
├── index.rst
├── views.rst
├── make.bat
├── Makefile
└── conf.py
├── .gitignore
├── MANIFEST.in
├── AUTHORS.rst
├── tox.ini
├── .travis.yml
├── setup.py
├── LICENSE
├── CHANGELOG.rst
├── runtests.py
└── README.rst
/extra_views/models.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/extra_views/contrib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/extra_views_tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/404.html:
--------------------------------------------------------------------------------
1 | 404
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal = 1
3 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/success.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Success
5 |
6 |
7 |
8 | Success
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | .project
3 | .pydevproject
4 | .coverage
5 | .DS_Store
6 | django_extra_views.egg-info/
7 | htmlcov/
8 | build/
9 | docs/_build/
10 | dist/
11 | .idea
12 | nosetests.xml
13 |
14 | /.tox/
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/event_calendar_month.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Event Calendar
5 |
6 |
7 |
8 | Event Calendar
9 |
10 | {{ object_list }}
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/item_list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Item List
5 |
6 |
7 |
8 | Item List
9 | {% for object in object_list %}
10 | {{ object }}
11 | {% endfor %}
12 |
13 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
3 | recursive-include extra_views *.py
4 | recursive-include extra_views/contrib *.py
5 | recursive-include extra_views/tests *.py
6 | recursive-include extra_views/tests/templates *.html
7 | recursive-include extra_views/tests/templates/extra_views *.html
--------------------------------------------------------------------------------
/extra_views/__init__.py:
--------------------------------------------------------------------------------
1 | from extra_views.formsets import FormSetView, ModelFormSetView, InlineFormSetView
2 | from extra_views.advanced import CreateWithInlinesView, UpdateWithInlinesView, InlineFormSet, NamedFormsetsMixin
3 | from extra_views.dates import CalendarMonthView
4 | from extra_views.contrib.mixins import SearchableListMixin, SortableListMixin
5 |
6 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/item_formset.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Item Formset
5 |
6 |
7 |
8 | Item Formset
9 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/paged_formset.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Item Formset
5 |
6 |
7 |
8 | Item Formset
9 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/inline_formset.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Inline Formset
5 |
6 |
7 |
8 | Inline Formset
9 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/address_formset.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Address Formset
5 |
6 |
7 |
8 | Address Formset
9 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/order_and_items.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Order and Items
5 |
6 |
7 |
8 | Order and Items
9 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/AUTHORS.rst:
--------------------------------------------------------------------------------
1 | Primary Author(s):
2 |
3 | * Andrew Ingram (https://github.com/AndrewIngram)
4 |
5 | Other Contributors:
6 |
7 | * Sergey Fursov (https://github.com/GeyseR)
8 | * Pavel Zhukov (https://github.com/zeus)
9 | * Piet Delport (https://github.com/pjdelport)
10 | * jgsogo (https://github.com/jgsogo)
11 | * Krzysiek Szularz (https://github.com/szuliq)
12 | * Miguel Restrepo (https://github.com/miguelrestrepo)
13 | * Henry Ward (https://bitbucket.org/henward0)
14 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/formsets_multiview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Order and Address MultiView
5 |
6 |
7 |
8 |
15 |
16 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/orderaddress_multiview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Order and Address MultiView
5 |
6 |
7 |
8 |
15 |
16 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/orderitems_multiview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Order and Items MultiView
5 |
6 |
7 |
8 |
15 |
16 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py{26,27}-django{16,17,18,19}
3 | py{34,35}-django{18,19}
4 | py{27,34,35}-djangomaster
5 | docs
6 |
7 | [testenv]
8 | setenv =
9 | PYTHONPATH = {toxinidir}
10 | commands =
11 | {toxinidir}/runtests.py --with-coverage
12 |
13 | deps =
14 | coverage
15 | django-nose
16 | django16: Django>=1.6,<1.7
17 | django17: Django>=1.7,<1.8
18 | django18: Django>=1.8,<1.9
19 | django19: Django>=1.9rc1,<1.10
20 | djangomaster: https://github.com/django/django/archive/master.tar.gz
21 |
22 | [testenv:docs]
23 | whitelist_externals=make
24 | changedir = docs
25 | deps =
26 | sphinx
27 | commands =
28 | make html
29 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to Django Extra Views' documentation!
2 | ==============================================
3 |
4 | Django Extra Views provides a number of additional class-based generic views to
5 | complement those provide by Django itself.
6 |
7 |
8 | Installation
9 | ------------
10 |
11 | Installing from pypi (using pip). ::
12 |
13 | pip install django-extra-views
14 |
15 | Installing from github. ::
16 |
17 | pip install -e git://github.com/AndrewIngram/django-extra-views.git#egg=django-extra-views
18 |
19 |
20 | Contents
21 | --------
22 |
23 | .. toctree::
24 | :maxdepth: 2
25 |
26 | views
27 | changelog
28 |
29 |
30 | Indices and tables
31 | ==================
32 |
33 | * :ref:`genindex`
34 | * :ref:`modindex`
35 | * :ref:`search`
36 |
37 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: python
3 |
4 | python:
5 | - "2.7"
6 | - "3.4"
7 | - "3.5"
8 |
9 | env:
10 | - DJANGO=django16
11 | - DJANGO=django17
12 | - DJANGO=django18
13 | - DJANGO=django19
14 | - DJANGO=djangomaster
15 |
16 | matrix:
17 | exclude:
18 | - python: "3.4"
19 | env: DJANGO=django16
20 | - python: "3.4"
21 | env: DJANGO=django17
22 | - python: "3.5"
23 | env: DJANGO=django16
24 | - python: "3.5"
25 | env: DJANGO=django17
26 | allow_failures:
27 | - env: DJANGO=djangomaster
28 |
29 | before_install:
30 | - pip install codecov
31 |
32 | install:
33 | - pip install tox
34 |
35 | script:
36 | - TOX_TEST_PASSENV="TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH" tox -e py${TRAVIS_PYTHON_VERSION//[.]/}-$DJANGO
37 |
38 | after_success:
39 | - codecov
40 |
--------------------------------------------------------------------------------
/extra_views_tests/formsets.py:
--------------------------------------------------------------------------------
1 | from django.forms.formsets import BaseFormSet
2 | from django.forms.models import BaseModelFormSet
3 | from django import forms
4 |
5 |
6 | COUNTRY_CHOICES = (
7 | ('gb', 'Great Britain'),
8 | ('us', 'United States'),
9 | ('ca', 'Canada'),
10 | ('au', 'Australia'),
11 | ('nz', 'New Zealand'),
12 | )
13 |
14 |
15 | class AddressFormSet(BaseFormSet):
16 | def add_fields(self, form, index):
17 | super(AddressFormSet, self).add_fields(form, index)
18 | form.fields['county'] = forms.ChoiceField(choices=COUNTRY_CHOICES, initial='gb')
19 |
20 |
21 | class BaseArticleFormSet(BaseModelFormSet):
22 | def add_fields(self, form, index):
23 | super(BaseArticleFormSet, self).add_fields(form, index)
24 | form.fields["notes"] = forms.CharField(initial="Write notes here")
25 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | name='django-extra-views',
5 | version='0.8.0',
6 | url='https://github.com/AndrewIngram/django-extra-views',
7 | install_requires=[
8 | 'Django >=1.6',
9 | 'six>=1.5.2',
10 | ],
11 | description="Extra class-based views for Django",
12 | long_description=open('README.rst', 'r').read(),
13 | license="MIT",
14 | author="Andrew Ingram",
15 | author_email="andy@andrewingram.net",
16 | packages=['extra_views', 'extra_views.contrib'],
17 | classifiers=[
18 | 'Development Status :: 3 - Alpha',
19 | 'Environment :: Web Environment',
20 | 'Framework :: Django',
21 | 'Intended Audience :: Developers',
22 | 'Programming Language :: Python',
23 | 'Programming Language :: Python :: 2',
24 | 'Programming Language :: Python :: 3']
25 | )
26 |
--------------------------------------------------------------------------------
/extra_views_tests/templates/extra_views/sortable_item_list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Item List
5 |
6 |
7 |
8 | Item List
9 |
10 |
11 |
12 | Name
13 | asc name
14 | desc name
15 | {% if sort_helper.is_sorted_by_name %} ordered by name {{ sort_helper.is_sorted_by_name }} {% endif %}
16 |
17 |
18 | SKU
19 |
20 |
21 | {% for object in object_list %}
22 |
23 | {{ object.name }}
24 | {{ object.sku }}
25 |
26 | {% endfor %}
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2012 Andrew Ingram
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/extra_views_tests/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from .models import Order, Item
3 |
4 |
5 | class OrderForm(forms.ModelForm):
6 | class Meta:
7 | model = Order
8 | fields = ['name']
9 |
10 | def save(self, commit=True):
11 | instance = super(OrderForm, self).save(commit=commit)
12 |
13 | if commit:
14 | instance.action_on_save = True
15 | instance.save()
16 |
17 | return instance
18 |
19 |
20 | class ItemForm(forms.ModelForm):
21 | flag = forms.BooleanField(initial=True)
22 |
23 | class Meta:
24 | model = Item
25 | fields = ['name', 'sku', 'price', 'order', 'status']
26 |
27 |
28 | class AddressForm(forms.Form):
29 | name = forms.CharField(max_length=255, required=True)
30 | line1 = forms.CharField(max_length=255, required=False)
31 | line2 = forms.CharField(max_length=255, required=False)
32 | city = forms.CharField(max_length=255, required=False)
33 | postcode = forms.CharField(max_length=10, required=True)
34 |
35 | def __init__(self, *args, **kwargs):
36 | self.user = kwargs.pop('user')
37 | super(AddressForm, self).__init__(*args, **kwargs)
38 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | Change History
2 | ==============
3 |
4 | 0.8 (2016-06-14)
5 | ----------------
6 |
7 | This version supports Django 1.6, 1.7, 1.8, 1.9 (latest minor versions), and Python 2.7, 3.4, 3.5 (latest minor versions).
8 |
9 | - Added ``widgets`` attribute setting; allow to change form widgets in the ``ModelFormSetView``.
10 | - Added Django 1.9 support.
11 | - Fixed ``get_context_data()`` usage of ``*args, **kwargs``.
12 | - Fixed silent overwriting of ``ModelForm`` fields to ``__all__``.
13 |
14 |
15 | Backwards-incompatible changes
16 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17 |
18 | - Dropped support for Django <= 1.5 and Python 3.3.
19 | - Removed the ``extra_views.multi`` module as it had neither documentation nor
20 | test coverage and was broken for some of the supported Django/Python versions.
21 | - This package no longer implicitly set ``fields = '__all__'``.
22 | If you face ``ImproperlyConfigured`` exceptions, you should have a look at the
23 | `Django 1.6 release notes`_ and set the ``fields`` or ``exclude`` attributes
24 | on your ``ModelForm`` or extra-views views.
25 |
26 | .. _Django 1.6 release notes: https://docs.djangoproject.com/en/stable/releases/1.6/#modelform-without-fields-or-exclude
27 |
28 |
29 | 0.7.1 (2015-06-15)
30 | ------------------
31 | Begin of this changelog.
32 |
--------------------------------------------------------------------------------
/extra_views_tests/models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | try:
3 | from django.utils.timezone import now
4 | except ImportError:
5 | now = datetime.datetime.now
6 | import django
7 | from django.db import models
8 | from django.contrib.contenttypes.models import ContentType
9 |
10 | if django.VERSION < (1, 8):
11 | from django.contrib.contenttypes.generic import GenericForeignKey
12 | else:
13 | from django.contrib.contenttypes.fields import GenericForeignKey
14 |
15 | STATUS_CHOICES = (
16 | (0, 'Placed'),
17 | (1, 'Charged'),
18 | (2, 'Shipped'),
19 | (3, 'Cancelled'),
20 | )
21 |
22 |
23 | class Order(models.Model):
24 | name = models.CharField(max_length=255)
25 | date_created = models.DateTimeField(auto_now_add=True)
26 | date_modified = models.DateTimeField(auto_now=True)
27 | action_on_save = models.BooleanField(default=False)
28 |
29 |
30 | class Item(models.Model):
31 | name = models.CharField(max_length=255)
32 | sku = models.CharField(max_length=13)
33 | price = models.DecimalField(decimal_places=2, max_digits=12, db_index=True)
34 | order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
35 | status = models.SmallIntegerField(default=0, choices=STATUS_CHOICES, db_index=True)
36 | date_placed = models.DateField(default=now, null=True, blank=True)
37 |
38 | def __unicode__(self):
39 | return '%s (%s)' % (self.name, self.sku)
40 |
41 |
42 | class Tag(models.Model):
43 | name = models.CharField(max_length=255)
44 | content_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE)
45 | object_id = models.PositiveIntegerField(null=True)
46 | content_object = GenericForeignKey('content_type', 'object_id')
47 |
48 | def __unicode__(self):
49 | return self.name
50 |
51 |
52 | class Event(models.Model):
53 | name = models.CharField(max_length=255)
54 | date = models.DateField()
55 |
56 | def __unicode__(self):
57 | return self.name
58 |
--------------------------------------------------------------------------------
/extra_views_tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import url
2 | from django.views.generic import TemplateView
3 | from .formsets import AddressFormSet
4 | from .views import AddressFormSetView, AddressFormSetViewNamed, ItemModelFormSetView, \
5 | FormAndFormSetOverrideView, PagedModelFormSetView, OrderItemFormSetView, \
6 | OrderCreateView, OrderUpdateView, OrderTagsView, EventCalendarView, OrderCreateNamedView, \
7 | SortableItemListView, SearchableItemListView
8 |
9 | urlpatterns = [
10 | url(r'^formset/simple/$', AddressFormSetView.as_view()),
11 | url(r'^formset/simple/named/$', AddressFormSetViewNamed.as_view()),
12 | url(r'^formset/simple_redirect/$', AddressFormSetView.as_view(success_url="/formset/simple_redirect/valid/")),
13 | url(r'^formset/simple_redirect/valid/$', TemplateView.as_view(template_name='extra_views/success.html')),
14 | url(r'^formset/custom/$', AddressFormSetView.as_view(formset_class=AddressFormSet)),
15 | url(r'^modelformset/simple/$', ItemModelFormSetView.as_view()),
16 | url(r'^modelformset/custom/$', FormAndFormSetOverrideView.as_view()),
17 | url(r'^modelformset/paged/$', PagedModelFormSetView.as_view()),
18 | url(r'^inlineformset/(?P\d+)/$', OrderItemFormSetView.as_view()),
19 | url(r'^inlines/(\d+)/new/$', OrderCreateView.as_view()),
20 | url(r'^inlines/new/$', OrderCreateView.as_view()),
21 | url(r'^inlines/new/named/$', OrderCreateNamedView.as_view()),
22 | url(r'^inlines/(?P\d+)/$', OrderUpdateView.as_view()),
23 | url(r'^genericinlineformset/(?P\d+)/$', OrderTagsView.as_view()),
24 | url(r'^sortable/(?P\w+)/$', SortableItemListView.as_view()),
25 | url(r'^events/(?P\d{4})/(?P\w+)/$', EventCalendarView.as_view()),
26 | url(r'^searchable/$', SearchableItemListView.as_view()),
27 | url(r'^searchable/predefined_query/$', SearchableItemListView.as_view(define_query=True)),
28 | url(r'^searchable/exact_query/$', SearchableItemListView.as_view(exact_query=True)),
29 | url(r'^searchable/wrong_lookup/$', SearchableItemListView.as_view(wrong_lookup=True)),
30 | ]
31 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import sys
3 | import logging
4 | from optparse import OptionParser
5 |
6 | from django.conf import settings
7 |
8 | logging.disable(logging.CRITICAL)
9 |
10 |
11 | def configure(nose_args=None):
12 | if not settings.configured:
13 | settings.configure(
14 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3'}},
15 | INSTALLED_APPS=[
16 | 'django.contrib.contenttypes',
17 | 'django.contrib.auth',
18 | 'extra_views',
19 | 'extra_views_tests',
20 | ],
21 | ROOT_URLCONF='extra_views_tests.urls',
22 | NOSE_ARGS=nose_args
23 | )
24 |
25 |
26 | def runtests(*test_args):
27 | from django_nose import NoseTestSuiteRunner
28 | runner = NoseTestSuiteRunner()
29 |
30 | if not test_args:
31 | test_args = ['extra_views_tests']
32 | num_failures = runner.run_tests(test_args)
33 | if num_failures:
34 | sys.exit(num_failures)
35 |
36 |
37 | if __name__ == '__main__':
38 | parser = OptionParser()
39 | parser.add_option('--with-coverage', dest='coverage', default=False,
40 | action='store_true')
41 | parser.add_option('--with-xunit', dest='xunit', default=False,
42 | action='store_true')
43 | parser.add_option('--with-spec', dest='with_spec', default=False,
44 | action='store_true')
45 | parser.add_option('--pdb', dest='pdb', default=False,
46 | action='store_true')
47 | options, args = parser.parse_args()
48 |
49 | nose_args = []
50 | if options.pdb:
51 | nose_args.append('--pdb')
52 |
53 | if options.coverage:
54 | # Nose automatically uses any options passed to runtests.py, which is
55 | # why the coverage trigger uses '--with-coverage' and why we don't need
56 | # to explicitly include it here.
57 | nose_args.extend([
58 | '--cover-package=extra_views', '--cover-branch', '--cover-html', '--cover-html-dir=htmlcov'])
59 | configure(nose_args)
60 | runtests()
61 |
--------------------------------------------------------------------------------
/extra_views/generic.py:
--------------------------------------------------------------------------------
1 | import django
2 |
3 | if django.VERSION < (1, 8):
4 | from django.contrib.contenttypes.generic import generic_inlineformset_factory, BaseGenericInlineFormSet
5 | else:
6 | from django.contrib.contenttypes.forms import generic_inlineformset_factory, BaseGenericInlineFormSet
7 |
8 | from extra_views.formsets import BaseInlineFormSetMixin, InlineFormSetMixin, BaseInlineFormSetView, InlineFormSetView
9 |
10 |
11 | class BaseGenericInlineFormSetMixin(BaseInlineFormSetMixin):
12 | """
13 | Base class for constructing an generic inline formset within a view
14 |
15 | IMPORTANT: Because of a Django bug, initial data doesn't work here.
16 | """
17 |
18 | ct_field = "content_type"
19 | ct_fk_field = "object_id"
20 | formset_class = BaseGenericInlineFormSet
21 |
22 | def get_formset_kwargs(self):
23 | kwargs = super(BaseGenericInlineFormSetMixin, self).get_formset_kwargs()
24 | return kwargs
25 |
26 | def get_factory_kwargs(self):
27 | """
28 | Returns the keyword arguments for calling the formset factory
29 | """
30 | kwargs = super(BaseGenericInlineFormSetMixin, self).get_factory_kwargs()
31 | del kwargs['fk_name']
32 | kwargs.update({
33 | "ct_field": self.ct_field,
34 | "fk_field": self.ct_fk_field,
35 | })
36 | return kwargs
37 |
38 | def get_formset(self):
39 | """
40 | Returns the final formset class from the inline formset factory
41 | """
42 | result = generic_inlineformset_factory(self.inline_model, **self.get_factory_kwargs())
43 | return result
44 |
45 |
46 | class GenericInlineFormSet(BaseGenericInlineFormSetMixin):
47 | """
48 | An inline class that provides a way to handle generic inline formsets
49 | """
50 |
51 | def __init__(self, parent_model, request, instance, view_kwargs=None, view=None):
52 | self.inline_model = self.model
53 | self.model = parent_model
54 | self.request = request
55 | self.object = instance
56 | self.kwargs = view_kwargs
57 | self.view = view
58 |
59 |
60 | class GenericInlineFormSetMixin(BaseGenericInlineFormSetMixin, InlineFormSetMixin):
61 | """
62 | A mixin that provides a way to show and handle a generic inline formset in a request.
63 | """
64 |
65 |
66 | class BaseGenericInlineFormSetView(GenericInlineFormSetMixin, BaseInlineFormSetView):
67 | """
68 | A base view for displaying a generic inline formset
69 | """
70 |
71 |
72 | class GenericInlineFormSetView(BaseGenericInlineFormSetView, InlineFormSetView):
73 | """
74 | A view for displaying a generic inline formset for a queryset belonging to a parent model
75 | """
76 |
--------------------------------------------------------------------------------
/extra_views_tests/views.py:
--------------------------------------------------------------------------------
1 | from extra_views import FormSetView, ModelFormSetView, InlineFormSetView, InlineFormSet, CreateWithInlinesView, UpdateWithInlinesView, CalendarMonthView, NamedFormsetsMixin, SortableListMixin, SearchableListMixin
2 | from extra_views.generic import GenericInlineFormSet, GenericInlineFormSetView
3 | from django.views import generic
4 | from .forms import AddressForm, ItemForm, OrderForm
5 | from .formsets import BaseArticleFormSet
6 | from .models import Item, Order, Tag, Event
7 |
8 |
9 | class AddressFormSetView(FormSetView):
10 | form_class = AddressForm
11 | template_name = 'extra_views/address_formset.html'
12 |
13 | def get_extra_form_kwargs(self):
14 | return {
15 | 'user': 'foo',
16 | }
17 |
18 |
19 | class AddressFormSetViewNamed(NamedFormsetsMixin, AddressFormSetView):
20 | inlines_names = ['AddressFormset']
21 |
22 |
23 | class ItemModelFormSetView(ModelFormSetView):
24 | model = Item
25 | fields = ['name', 'sku', 'price', 'order', 'status']
26 | template_name = 'extra_views/item_formset.html'
27 |
28 |
29 | class FormAndFormSetOverrideView(ModelFormSetView):
30 | model = Item
31 | form_class = ItemForm
32 | formset_class = BaseArticleFormSet
33 | template_name = 'extra_views/item_formset.html'
34 |
35 |
36 | class OrderItemFormSetView(InlineFormSetView):
37 | model = Order
38 | fields = ['name', 'sku', 'price', 'order', 'status']
39 | inline_model = Item
40 | template_name = "extra_views/inline_formset.html"
41 |
42 |
43 | class PagedModelFormSetView(ModelFormSetView):
44 | paginate_by = 2
45 | model = Item
46 | template_name = 'extra_views/paged_formset.html'
47 |
48 |
49 | class ItemsInline(InlineFormSet):
50 | model = Item
51 | fields = ['name', 'sku', 'price', 'order', 'status']
52 |
53 |
54 | class TagsInline(GenericInlineFormSet):
55 | model = Tag
56 | fields = ['name']
57 |
58 |
59 | class OrderCreateView(CreateWithInlinesView):
60 | model = Order
61 | fields = ['name']
62 | context_object_name = 'order'
63 | inlines = [ItemsInline, TagsInline]
64 | template_name = 'extra_views/order_and_items.html'
65 |
66 | def get_success_url(self):
67 | return '/inlines/%i' % self.object.pk
68 |
69 |
70 | class OrderCreateNamedView(NamedFormsetsMixin, OrderCreateView):
71 | inlines_names = ['Items', 'Tags']
72 |
73 |
74 | class OrderUpdateView(UpdateWithInlinesView):
75 | model = Order
76 | form_class = OrderForm
77 | inlines = [ItemsInline, TagsInline]
78 | template_name = 'extra_views/order_and_items.html'
79 |
80 | def get_success_url(self):
81 | return ''
82 |
83 |
84 | class OrderTagsView(GenericInlineFormSetView):
85 | model = Order
86 | inline_model = Tag
87 | template_name = "extra_views/inline_formset.html"
88 |
89 |
90 | class EventCalendarView(CalendarMonthView):
91 | template_name = 'extra_views/event_calendar_month.html'
92 | model = Event
93 | month_format = '%b'
94 | date_field = 'date'
95 |
96 |
97 | class SearchableItemListView(SearchableListMixin, generic.ListView):
98 | template_name = 'extra_views/item_list.html'
99 | search_fields = ['name', 'sku']
100 | search_date_fields = ['date_placed']
101 | model = Item
102 | define_query = False
103 | exact_query = False
104 | wrong_lookup = False
105 |
106 | def get_search_query(self):
107 | if self.define_query:
108 | return 'test B'
109 | else:
110 | return super(SearchableItemListView, self).get_search_query()
111 |
112 | def get(self, request, *args, **kwargs):
113 | if self.exact_query:
114 | self.search_fields = [('name', 'iexact'), 'sku']
115 | elif self.wrong_lookup:
116 | self.search_fields = [('name', 'gte'), 'sku']
117 | return super(SearchableItemListView, self).get(request, *args, **kwargs)
118 |
119 |
120 | class SortableItemListView(SortableListMixin, generic.ListView):
121 | template_name = 'extra_views/sortable_item_list.html'
122 | sort_fields = ['name', 'sku']
123 | model = Item
124 |
125 | def get(self, request, *args, **kwargs):
126 | if kwargs['flag'] == 'fields_and_aliases':
127 | self.sort_fields_aliases = [('name', 'by_name'), ('sku', 'by_sku'), ]
128 | elif kwargs['flag'] == 'aliases':
129 | self.sort_fields_aliases = [('name', 'by_name'), ('sku', 'by_sku'), ]
130 | self.sort_fields = []
131 | return super(SortableItemListView, self).get(request, *args, **kwargs)
132 |
--------------------------------------------------------------------------------
/docs/views.rst:
--------------------------------------------------------------------------------
1 | Views
2 | =====
3 |
4 | For all of these views we've tried to mimic the API of Django's existing class-based
5 | views as closely as possible, so they should feel natural to anyone who's already
6 | familiar with Django's views.
7 |
8 |
9 | FormSetView
10 | -----------
11 |
12 | This is the formset equivalent of Django's FormView. Use it when you want to
13 | display a single (non-model) formset on a page.
14 |
15 | A simple formset::
16 |
17 | from extra_views import FormSetView
18 | from foo.forms import MyForm
19 |
20 |
21 | class MyFormSetView(FormSetView):
22 | template_name = 'myformset.html'
23 | form_class = MyForm
24 | success_url = 'success/'
25 |
26 | def get_initial(self):
27 | # return whatever you'd normally use as the initial data for your formset.
28 | return data
29 |
30 | def formset_valid(self, formset):
31 | # do stuff
32 | return super(MyFormSetView, self).formset_valid(formset)
33 |
34 | This view will render the template :code:`myformset.html` with a context variable
35 | :code:`formset` representing the formset of MyForm. Once POSTed and successfully
36 | validated, :code:`formset_valid` will be called which is where your handling logic
37 | goes, then it redirects to :code:`success_url`.
38 |
39 | FormSetView exposes all the parameters you'd normally be able to pass to
40 | formset_factory. Example (using the default settings)::
41 |
42 | class MyFormSetView(FormSetView):
43 | template_name = 'myformset.html'
44 | form_class = MyForm
45 | success_url = 'success/'
46 | extra = 2
47 | max_num = None
48 | can_order = False
49 | can_delete = False
50 |
51 | ...
52 |
53 |
54 | ModelFormSetView
55 | ----------------
56 |
57 | ModelFormSetView makes use of Django's modelformset_factory, using the
58 | declarative syntax used in FormSetView as well as Django's own class-based
59 | views. So as you'd expect, the simplest usage is as follows::
60 |
61 | from extra_views import ModelFormSetView
62 | from foo.models import MyModel
63 |
64 |
65 | class MyModelFormSetView(ModelFormSetView):
66 | template_name = 'mymodelformset.html'
67 | model = MyModel
68 |
69 | Like :code:`FormSetView`, the :code:`formset` variable is made available in the template
70 | context. By default this will populate the formset with all the instances of
71 | :code:`MyModel` in the database. You can control this by overriding :code:`get_queryset` on
72 | the class, which could filter on a URL kwarg (:code:`self.kwargs`), for example::
73 |
74 | class MyModelFormSetView(ModelFormSetView):
75 | template_name = 'mymodelformset.html'
76 | model = MyModel
77 |
78 | def get_queryset(self):
79 | slug = self.kwargs['slug']
80 | return super(MyModelFormSetView, self).get_queryset().filter(slug=slug)
81 |
82 |
83 | InlineFormSetView
84 | -----------------
85 |
86 | When you want to edit models related to a parent model (using a ForeignKey),
87 | you'll want to use InlineFormSetView. An example use case would be editing user
88 | reviews related to a product::
89 |
90 | from extra_views import InlineFormSetView
91 |
92 |
93 | class EditProductReviewsView(InlineFormSetView):
94 | model = Product
95 | inline_model = Review
96 |
97 | ...
98 |
99 | Aside from the use of `model` and `inline_model`, InlineFormSetView works
100 | more-or-less in the same way as ModelFormSetView.
101 |
102 |
103 | GenericInlineFormSetView
104 | ------------------------
105 |
106 | You can also use generic relationships for your inline formsets, this makes use
107 | of Django's :code:`generic_inlineformset_factory`. The usage is the same, but with the
108 | addition of :code:`ct_field` and :code:`ct_fk_field`::
109 |
110 | from extra_views.generic import GenericInlineFormSetView
111 |
112 |
113 | class EditProductReviewsView(GenericInlineFormSetView):
114 | model = Product
115 | inline_model = Review
116 | ct_field = "content_type"
117 | ct_fk_field = "object_id"
118 | max_num = 1
119 |
120 | ...
121 |
122 |
123 | CreateWithInlinesView and UpdateWithInlinesView
124 | -----------------------------------------------
125 |
126 | These are the most powerful views in the library, they are effectively
127 | replacements for Django's own :code:`CreateView` and :code:`UpdateView`. The key difference is
128 | that they let you include any number of inline formsets (as well as the parent
129 | model's form), this provides functionality much like the Django Admin change
130 | forms. The API should be fairly familiar as well. The list of the inlines will
131 | be passed to the template as context variable `inlines`.
132 |
133 | Here is a simple example that demonstrates the use of each view with both normal
134 | inline relationships and generic inlines::
135 |
136 | from extra_views import InlineFormSet, CreateWithInlinesView, UpdateWithInlinesView
137 | from extra_views.generic import GenericInlineFormSet
138 |
139 |
140 | class ItemsInline(InlineFormSet):
141 | model = Item
142 |
143 |
144 | class TagsInline(GenericInlineFormSet):
145 | model = Tag
146 |
147 |
148 | class OrderCreateView(CreateWithInlinesView):
149 | model = Order
150 | inlines = [ItemsInline, TagsInline]
151 |
152 | def get_success_url(self):
153 | return self.object.get_absolute_url()
154 |
155 |
156 | class OrderUpdateView(UpdateWithInlinesView):
157 | model = Order
158 | form_class = OrderForm
159 | inlines = [ItemsInline, TagsInline]
160 |
161 | def get_success_url(self):
162 | return self.object.get_absolute_url()
163 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=_build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
10 | set I18NSPHINXOPTS=%SPHINXOPTS% .
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. linkcheck to check all external links for integrity
37 | echo. doctest to run all doctests embedded in the documentation if enabled
38 | goto end
39 | )
40 |
41 | if "%1" == "clean" (
42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
43 | del /q /s %BUILDDIR%\*
44 | goto end
45 | )
46 |
47 | if "%1" == "html" (
48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
49 | if errorlevel 1 exit /b 1
50 | echo.
51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
52 | goto end
53 | )
54 |
55 | if "%1" == "dirhtml" (
56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
57 | if errorlevel 1 exit /b 1
58 | echo.
59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
60 | goto end
61 | )
62 |
63 | if "%1" == "singlehtml" (
64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
65 | if errorlevel 1 exit /b 1
66 | echo.
67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
68 | goto end
69 | )
70 |
71 | if "%1" == "pickle" (
72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
73 | if errorlevel 1 exit /b 1
74 | echo.
75 | echo.Build finished; now you can process the pickle files.
76 | goto end
77 | )
78 |
79 | if "%1" == "json" (
80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
81 | if errorlevel 1 exit /b 1
82 | echo.
83 | echo.Build finished; now you can process the JSON files.
84 | goto end
85 | )
86 |
87 | if "%1" == "htmlhelp" (
88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
89 | if errorlevel 1 exit /b 1
90 | echo.
91 | echo.Build finished; now you can run HTML Help Workshop with the ^
92 | .hhp project file in %BUILDDIR%/htmlhelp.
93 | goto end
94 | )
95 |
96 | if "%1" == "qthelp" (
97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
98 | if errorlevel 1 exit /b 1
99 | echo.
100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
101 | .qhcp project file in %BUILDDIR%/qthelp, like this:
102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DjangoExtraViews.qhcp
103 | echo.To view the help file:
104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DjangoExtraViews.ghc
105 | goto end
106 | )
107 |
108 | if "%1" == "devhelp" (
109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
110 | if errorlevel 1 exit /b 1
111 | echo.
112 | echo.Build finished.
113 | goto end
114 | )
115 |
116 | if "%1" == "epub" (
117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
118 | if errorlevel 1 exit /b 1
119 | echo.
120 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
121 | goto end
122 | )
123 |
124 | if "%1" == "latex" (
125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
129 | goto end
130 | )
131 |
132 | if "%1" == "text" (
133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
134 | if errorlevel 1 exit /b 1
135 | echo.
136 | echo.Build finished. The text files are in %BUILDDIR%/text.
137 | goto end
138 | )
139 |
140 | if "%1" == "man" (
141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
142 | if errorlevel 1 exit /b 1
143 | echo.
144 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
145 | goto end
146 | )
147 |
148 | if "%1" == "texinfo" (
149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
150 | if errorlevel 1 exit /b 1
151 | echo.
152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
153 | goto end
154 | )
155 |
156 | if "%1" == "gettext" (
157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
158 | if errorlevel 1 exit /b 1
159 | echo.
160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
161 | goto end
162 | )
163 |
164 | if "%1" == "changes" (
165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
166 | if errorlevel 1 exit /b 1
167 | echo.
168 | echo.The overview file is in %BUILDDIR%/changes.
169 | goto end
170 | )
171 |
172 | if "%1" == "linkcheck" (
173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
174 | if errorlevel 1 exit /b 1
175 | echo.
176 | echo.Link check complete; look for any errors in the above output ^
177 | or in %BUILDDIR%/linkcheck/output.txt.
178 | goto end
179 | )
180 |
181 | if "%1" == "doctest" (
182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
183 | if errorlevel 1 exit /b 1
184 | echo.
185 | echo.Testing of doctests in the sources finished, look at the ^
186 | results in %BUILDDIR%/doctest/output.txt.
187 | goto end
188 | )
189 |
190 | :end
191 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | |travis| |codecov|
2 |
3 | django-extra-views - The missing class-based generic views for Django
4 | ========================================================================
5 |
6 | Django's class-based generic views are great, they let you accomplish a large number of web application design patterns in relatively few lines of code. They do have their limits though, and that's what this library of views aims to overcome.
7 |
8 | .. |travis| image:: https://secure.travis-ci.org/AndrewIngram/django-extra-views.svg?branch=master
9 | :target: https://travis-ci.org/AndrewIngram/django-extra-views
10 |
11 | .. |codecov| image:: https://codecov.io/github/AndrewIngram/django-extra-views/coverage.svg?branch=master
12 | :target: https://codecov.io/github/AndrewIngram/django-extra-views?branch=master
13 |
14 |
15 | Installation
16 | ------------
17 |
18 | Installing from pypi (using pip). ::
19 |
20 | pip install django-extra-views
21 |
22 | Installing from github. ::
23 |
24 | pip install -e git://github.com/AndrewIngram/django-extra-views.git#egg=django-extra-views
25 |
26 |
27 | See the `documentation here`_
28 |
29 | .. _documentation here: https://django-extra-views.readthedocs.org/en/latest/
30 |
31 | Features so far
32 | ------------------
33 |
34 | - FormSet and ModelFormSet views - The formset equivalents of FormView and ModelFormView.
35 | - InlineFormSetView - Lets you edit formsets related to a model (uses inlineformset_factory)
36 | - CreateWithInlinesView and UpdateWithInlinesView - Lets you edit a model and its relations
37 | - GenericInlineFormSetView, the equivalent of InlineFormSetView but for GenericForeignKeys
38 | - Support for generic inlines in CreateWithInlinesView and UpdateWithInlinesView
39 | - Support for naming each inline or formset with NamedFormsetsMixin
40 | - SortableListMixin - Generic mixin for sorting functionality in your views
41 | - SearchableListMixin - Generic mixin for search functionality in your views
42 |
43 | Still to do
44 | -----------
45 |
46 | I'd like to add support for pagination in ModelFormSetView and its derivatives, the goal being to be able to mimic the change_list view in Django's admin. Currently this is proving difficult because of how Django's MultipleObjectMixin handles pagination.
47 |
48 | Examples
49 | --------
50 |
51 | Defining a FormSetView. ::
52 |
53 | from extra_views import FormSetView
54 |
55 |
56 | class AddressFormSet(FormSetView):
57 | form_class = AddressForm
58 | template_name = 'address_formset.html'
59 |
60 | Defining a ModelFormSetView. ::
61 |
62 | from extra_views import ModelFormSetView
63 |
64 |
65 | class ItemFormSetView(ModelFormSetView):
66 | model = Item
67 | template_name = 'item_formset.html'
68 |
69 | Defining a CreateWithInlinesView and an UpdateWithInlinesView. ::
70 |
71 | from extra_views import CreateWithInlinesView, UpdateWithInlinesView, InlineFormSet
72 | from extra_views.generic import GenericInlineFormSet
73 |
74 |
75 | class ItemInline(InlineFormSet):
76 | model = Item
77 |
78 |
79 | class TagInline(GenericInlineFormSet):
80 | model = Tag
81 |
82 |
83 | class CreateOrderView(CreateWithInlinesView):
84 | model = Order
85 | inlines = [ItemInline, TagInline]
86 |
87 |
88 | class UpdateOrderView(UpdateWithInlinesView):
89 | model = Order
90 | inlines = [ItemInline, TagInline]
91 |
92 |
93 | # Example URLs.
94 | urlpatterns = patterns('',
95 | url(r'^orders/new/$', CreateOrderView.as_view()),
96 | url(r'^orders/(?P\d+)/$', UpdateOrderView.as_view()),
97 | )
98 |
99 | Other bits of functionality
100 | ---------------------------
101 |
102 | If you want more control over the names of your formsets (as opposed to iterating over inlines), you can use NamedFormsetsMixin. ::
103 |
104 | from extra_views import NamedFormsetsMixin
105 |
106 | class CreateOrderView(NamedFormsetsMixin, CreateWithInlinesView):
107 | model = Order
108 | inlines = [ItemInline, TagInline]
109 | inlines_names = ['Items', 'Tags']
110 |
111 | You can add search functionality to your ListViews by adding SearchableMixin and by setting search_fields::
112 |
113 | from django.views.generic import ListView
114 | from extra_views import SearchableListMixin
115 |
116 | class SearchableItemListView(SearchableListMixin, ListView):
117 | template_name = 'extra_views/item_list.html'
118 | search_fields = ['name', 'sku']
119 | model = Item
120 |
121 | In this case ``object_list`` will be filtered if the 'q' query string is provided (like /searchable/?q=query), or you
122 | can manually override ``get_search_query`` method, to define your own search functionality.
123 |
124 | Also you can define some items in ``search_fields`` as tuple (e.g. ``[('name', 'iexact', ), 'sku']``)
125 | to provide custom lookups for searching. Default lookup is ``icontains``. We strongly recommend to use only
126 | string lookups, when number fields will convert to strings before comparison to prevent converting errors.
127 | This controlled by ``check_lookups`` setting of SearchableMixin.
128 |
129 | Define sorting in view. ::
130 |
131 | from django.views.generic import ListView
132 | from extra_views import SortableListMixin
133 |
134 | class SortableItemListView(SortableListMixin, ListView):
135 | sort_fields_aliases = [('name', 'by_name'), ('id', 'by_id'), ]
136 | model = Item
137 |
138 | You can hide real field names in query string by define sort_fields_aliases attribute (see example)
139 | or show they as is by define sort_fields. SortableListMixin adds ``sort_helper`` variable of SortHelper class,
140 | then in template you can use helper functions: ``{{ sort_helper.get_sort_query_by_FOO }}``,
141 | ``{{ sort_helper.get_sort_query_by_FOO_asc }}``, ``{{ sort_helper.get_sort_query_by_FOO_desc }}`` and
142 | ``{{ sort_helper.is_sorted_by_FOO }}``
143 |
144 | More descriptive examples to come.
145 |
--------------------------------------------------------------------------------
/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 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 | # the i18n builder cannot share the environment and doctrees with the others
15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
16 |
17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
18 |
19 | help:
20 | @echo "Please use \`make ' where is one of"
21 | @echo " html to make standalone HTML files"
22 | @echo " dirhtml to make HTML files named index.html in directories"
23 | @echo " singlehtml to make a single large HTML file"
24 | @echo " pickle to make pickle files"
25 | @echo " json to make JSON files"
26 | @echo " htmlhelp to make HTML files and a HTML help project"
27 | @echo " qthelp to make HTML files and a qthelp project"
28 | @echo " devhelp to make HTML files and a Devhelp project"
29 | @echo " epub to make an epub"
30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
31 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
32 | @echo " text to make text files"
33 | @echo " man to make manual pages"
34 | @echo " texinfo to make Texinfo files"
35 | @echo " info to make Texinfo files and run them through makeinfo"
36 | @echo " gettext to make PO message catalogs"
37 | @echo " changes to make an overview of all changed/added/deprecated items"
38 | @echo " linkcheck to check all external links for integrity"
39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
40 |
41 | clean:
42 | -rm -rf $(BUILDDIR)/*
43 |
44 | html:
45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
46 | @echo
47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
48 |
49 | dirhtml:
50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
51 | @echo
52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
53 |
54 | singlehtml:
55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
56 | @echo
57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
58 |
59 | pickle:
60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
61 | @echo
62 | @echo "Build finished; now you can process the pickle files."
63 |
64 | json:
65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
66 | @echo
67 | @echo "Build finished; now you can process the JSON files."
68 |
69 | htmlhelp:
70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
71 | @echo
72 | @echo "Build finished; now you can run HTML Help Workshop with the" \
73 | ".hhp project file in $(BUILDDIR)/htmlhelp."
74 |
75 | qthelp:
76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
77 | @echo
78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoExtraViews.qhcp"
81 | @echo "To view the help file:"
82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoExtraViews.qhc"
83 |
84 | devhelp:
85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
86 | @echo
87 | @echo "Build finished."
88 | @echo "To view the help file:"
89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoExtraViews"
90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoExtraViews"
91 | @echo "# devhelp"
92 |
93 | epub:
94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
95 | @echo
96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
97 |
98 | latex:
99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
100 | @echo
101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
103 | "(use \`make latexpdf' here to do that automatically)."
104 |
105 | latexpdf:
106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
107 | @echo "Running LaTeX files through pdflatex..."
108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
110 |
111 | text:
112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
113 | @echo
114 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
115 |
116 | man:
117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
118 | @echo
119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
120 |
121 | texinfo:
122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
123 | @echo
124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
125 | @echo "Run \`make' in that directory to run these through makeinfo" \
126 | "(use \`make info' here to do that automatically)."
127 |
128 | info:
129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
130 | @echo "Running Texinfo files through makeinfo..."
131 | make -C $(BUILDDIR)/texinfo info
132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
133 |
134 | gettext:
135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
136 | @echo
137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
138 |
139 | changes:
140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
141 | @echo
142 | @echo "The overview file is in $(BUILDDIR)/changes."
143 |
144 | linkcheck:
145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
146 | @echo
147 | @echo "Link check complete; look for any errors in the above output " \
148 | "or in $(BUILDDIR)/linkcheck/output.txt."
149 |
150 | doctest:
151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
152 | @echo "Testing of doctests in the sources finished, look at the " \
153 | "results in $(BUILDDIR)/doctest/output.txt."
154 |
--------------------------------------------------------------------------------
/extra_views/advanced.py:
--------------------------------------------------------------------------------
1 | import django
2 | from django.views.generic.base import ContextMixin
3 | from django.views.generic.edit import FormView, ModelFormMixin
4 | from django.views.generic.detail import SingleObjectTemplateResponseMixin
5 | from extra_views.formsets import BaseInlineFormSetMixin
6 | from django.http import HttpResponseRedirect
7 | from django.forms.formsets import all_valid
8 |
9 |
10 | class InlineFormSet(BaseInlineFormSetMixin):
11 | """
12 | Base class for constructing an inline formset within a view
13 | """
14 |
15 | def __init__(self, parent_model, request, instance, view_kwargs=None, view=None):
16 | self.inline_model = self.model
17 | self.model = parent_model
18 | self.request = request
19 | self.object = instance
20 | self.kwargs = view_kwargs
21 | self.view = view
22 |
23 | def construct_formset(self):
24 | """
25 | Overrides construct_formset to attach the model class as
26 | an attribute of the returned formset instance.
27 | """
28 | formset = super(InlineFormSet, self).construct_formset()
29 | formset.model = self.inline_model
30 | return formset
31 |
32 |
33 | class ModelFormWithInlinesMixin(ModelFormMixin):
34 | """
35 | A mixin that provides a way to show and handle a modelform and inline
36 | formsets in a request.
37 | """
38 | inlines = []
39 |
40 | def get_inlines(self):
41 | """
42 | Returns the inline formset classes
43 | """
44 | return self.inlines
45 |
46 | def forms_valid(self, form, inlines):
47 | """
48 | If the form and formsets are valid, save the associated models.
49 | """
50 | self.object = form.save()
51 | for formset in inlines:
52 | formset.save()
53 | return HttpResponseRedirect(self.get_success_url())
54 |
55 | def forms_invalid(self, form, inlines):
56 | """
57 | If the form or formsets are invalid, re-render the context data with the
58 | data-filled form and formsets and errors.
59 | """
60 | return self.render_to_response(self.get_context_data(form=form, inlines=inlines))
61 |
62 | def construct_inlines(self):
63 | """
64 | Returns the inline formset instances
65 | """
66 | inline_formsets = []
67 | for inline_class in self.get_inlines():
68 | inline_instance = inline_class(self.model, self.request, self.object, self.kwargs, self)
69 | inline_formset = inline_instance.construct_formset()
70 | inline_formsets.append(inline_formset)
71 | return inline_formsets
72 |
73 |
74 | class ProcessFormWithInlinesView(FormView):
75 | """
76 | A mixin that renders a form and inline formsets on GET and processes it on POST.
77 | """
78 |
79 | def get(self, request, *args, **kwargs):
80 | """
81 | Handles GET requests and instantiates a blank version of the form and formsets.
82 | """
83 | form_class = self.get_form_class()
84 | form = self.get_form(form_class)
85 | inlines = self.construct_inlines()
86 | return self.render_to_response(self.get_context_data(form=form, inlines=inlines, **kwargs))
87 |
88 | def post(self, request, *args, **kwargs):
89 | """
90 | Handles POST requests, instantiating a form and formset instances with the passed
91 | POST variables and then checked for validity.
92 | """
93 | form_class = self.get_form_class()
94 | form = self.get_form(form_class)
95 |
96 | if form.is_valid():
97 | self.object = form.save(commit=False)
98 | form_validated = True
99 | else:
100 | form_validated = False
101 |
102 | inlines = self.construct_inlines()
103 |
104 | if all_valid(inlines) and form_validated:
105 | return self.forms_valid(form, inlines)
106 | return self.forms_invalid(form, inlines)
107 |
108 | # PUT is a valid HTTP verb for creating (with a known URL) or editing an
109 | # object, note that browsers only support POST for now.
110 | def put(self, *args, **kwargs):
111 | return self.post(*args, **kwargs)
112 |
113 |
114 | class BaseCreateWithInlinesView(ModelFormWithInlinesMixin, ProcessFormWithInlinesView):
115 | """
116 | Base view for creating an new object instance with related model instances.
117 |
118 | Using this base class requires subclassing to provide a response mixin.
119 | """
120 |
121 | def get(self, request, *args, **kwargs):
122 | self.object = None
123 | return super(BaseCreateWithInlinesView, self).get(request, *args, **kwargs)
124 |
125 | def post(self, request, *args, **kwargs):
126 | self.object = None
127 | return super(BaseCreateWithInlinesView, self).post(request, *args, **kwargs)
128 |
129 |
130 | class CreateWithInlinesView(SingleObjectTemplateResponseMixin, BaseCreateWithInlinesView):
131 | """
132 | View for creating a new object instance with related model instances,
133 | with a response rendered by template.
134 | """
135 | template_name_suffix = '_form'
136 |
137 |
138 | class BaseUpdateWithInlinesView(ModelFormWithInlinesMixin, ProcessFormWithInlinesView):
139 | """
140 | Base view for updating an existing object with related model instances.
141 |
142 | Using this base class requires subclassing to provide a response mixin.
143 | """
144 |
145 | def get(self, request, *args, **kwargs):
146 | self.object = self.get_object()
147 | return super(BaseUpdateWithInlinesView, self).get(request, *args, **kwargs)
148 |
149 | def post(self, request, *args, **kwargs):
150 | self.object = self.get_object()
151 | return super(BaseUpdateWithInlinesView, self).post(request, *args, **kwargs)
152 |
153 |
154 | class UpdateWithInlinesView(SingleObjectTemplateResponseMixin, BaseUpdateWithInlinesView):
155 | """
156 | View for updating an object with related model instances,
157 | with a response rendered by template.
158 | """
159 | template_name_suffix = '_form'
160 |
161 |
162 | class NamedFormsetsMixin(ContextMixin):
163 | """
164 | A mixin for use with `CreateWithInlinesView` or `UpdateWithInlinesView` that lets
165 | you define the context variable for each inline.
166 | """
167 | inlines_names = []
168 |
169 | def get_inlines_names(self):
170 | """
171 | Returns a list of names of context variables for each inline in `inlines`.
172 | """
173 | return self.inlines_names
174 |
175 | def get_context_data(self, **kwargs):
176 | """
177 | If `inlines_names` has been defined, add each formset to the context under
178 | its corresponding entry in `inlines_names`
179 | """
180 | context = {}
181 | inlines_names = self.get_inlines_names()
182 |
183 | if inlines_names:
184 | # We have formset or inlines in context, but never both
185 | context.update(zip(inlines_names, kwargs.get('inlines', [])))
186 | if 'formset' in kwargs:
187 | context[inlines_names[0]] = kwargs['formset']
188 | context.update(kwargs)
189 | return super(NamedFormsetsMixin, self).get_context_data(**context)
190 |
--------------------------------------------------------------------------------
/extra_views/contrib/mixins.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import datetime
4 | import functools
5 | import operator
6 |
7 | from django.views.generic.base import ContextMixin
8 | from django.core.exceptions import ImproperlyConfigured
9 | from django.db.models import Q
10 |
11 | import six
12 | from six.moves import reduce
13 |
14 |
15 |
16 | VALID_STRING_LOOKUPS = (
17 | 'iexact', 'contains', 'icontains', 'startswith', 'istartswith', 'endswith',
18 | 'iendswith', 'search', 'regex', 'iregex')
19 |
20 |
21 | class SearchableListMixin(object):
22 | """
23 | Filter queryset like a django admin search_fields does, but with little more intelligence:
24 | if self.search_split is set to True (by default) it will split query to words (by whitespace)
25 | Also tries to convert each word to date with self.search_date_formats and then search each word in separate field
26 | e.g. with query 'foo bar' you can find object with obj.field1__icontains='foo' and obj.field2__icontains=='bar'
27 |
28 | To provide custom lookup just set one of the search_fields to tuple,
29 | e.g. search_fields = [('field1', 'iexact'), 'field2', ('field3', 'startswith')]
30 |
31 | This class is designed to be used with django.generic.ListView
32 |
33 | You could specify query by overriding get_search_query method
34 | by default this method will try to get 'q' key from request.GET (this can be disabled with search_use_q=False)
35 | """
36 | search_fields = ['id']
37 | search_date_fields = None
38 | search_date_formats = ['%d.%m.%y', '%d.%m.%Y']
39 | search_split = True
40 | search_use_q = True
41 | check_lookups = True
42 |
43 | def get_words(self, query):
44 | if self.search_split:
45 | return query.split()
46 | return [query]
47 |
48 | def get_search_fields_with_filters(self):
49 | fields = []
50 | for sf in self.search_fields:
51 | if isinstance(sf, six.string_types):
52 | fields.append((sf, 'icontains', ))
53 | else:
54 | if self.check_lookups and sf[1] not in VALID_STRING_LOOKUPS:
55 | raise ValueError('Invalid string lookup - %s' % sf[1])
56 | fields.append(sf)
57 | return fields
58 |
59 | def try_convert_to_date(self, word):
60 | """
61 | Tries to convert word to date(datetime) using search_date_formats
62 | Return None if word fits no one format
63 | """
64 | for frm in self.search_date_formats:
65 | try:
66 | return datetime.datetime.strptime(word, frm).date()
67 | except ValueError:
68 | pass
69 | return None
70 |
71 | def get_search_query(self):
72 | """
73 | Get query from request.GET 'q' parameter when search_use_q is set to True
74 | Override this method to provide your own query to search
75 | """
76 | return self.search_use_q and self.request.GET.get('q', '') or None
77 |
78 | def get_queryset(self):
79 | qs = super(SearchableListMixin, self).get_queryset()
80 | query = self.get_search_query()
81 | if query:
82 | w_qs = []
83 | search_pairs = self.get_search_fields_with_filters()
84 | for word in self.get_words(query):
85 | filters = [Q(**{'%s__%s' % (pair[0], pair[1]): word}) for pair in search_pairs]
86 | if self.search_date_fields:
87 | dt = self.try_convert_to_date(word)
88 | if dt:
89 | filters.extend([Q(**{field_name: dt}) for field_name in self.search_date_fields])
90 | w_qs.append(reduce(operator.or_, filters))
91 | qs = qs.filter(reduce(operator.and_, w_qs))
92 | qs = qs.distinct()
93 | return qs
94 |
95 |
96 | class SortHelper(object):
97 | def __init__(self, request, sort_fields_aliases, sort_param_name, sort_type_param_name):
98 | # Create a list from sort_fields_aliases, in case it is a generator,
99 | # since we want to iterate through it multiple times.
100 | sort_fields_aliases = list(sort_fields_aliases)
101 |
102 | self.initial_params = request.GET.copy()
103 | self.sort_fields = dict(sort_fields_aliases)
104 | self.inv_sort_fields = dict((v, k) for k, v in sort_fields_aliases)
105 | self.initial_sort = self.inv_sort_fields.get(self.initial_params.get(sort_param_name), None)
106 | self.initial_sort_type = self.initial_params.get(sort_type_param_name, 'asc')
107 | self.sort_param_name = sort_param_name
108 | self.sort_type_param_name = sort_type_param_name
109 |
110 | for field, alias in self.sort_fields.items():
111 | setattr(self, 'get_sort_query_by_%s' % alias, functools.partial(self.get_params_for_field, field))
112 | setattr(self, 'get_sort_query_by_%s_asc' % alias, functools.partial(self.get_params_for_field, field, 'asc'))
113 | setattr(self, 'get_sort_query_by_%s_desc' % alias, functools.partial(self.get_params_for_field, field, 'desc'))
114 | setattr(self, 'is_sorted_by_%s' % alias, functools.partial(self.is_sorted_by, field))
115 |
116 | def is_sorted_by(self, field_name):
117 | return field_name == self.initial_sort and self.initial_sort_type or False
118 |
119 | def get_params_for_field(self, field_name, sort_type=None):
120 | """
121 | If sort_type is None - inverse current sort for field, if no sorted - use asc
122 | """
123 | if not sort_type:
124 | if self.initial_sort == field_name:
125 | sort_type = 'desc' if self.initial_sort_type == 'asc' else 'asc'
126 | else:
127 | sort_type = 'asc'
128 | self.initial_params[self.sort_param_name] = self.sort_fields[field_name]
129 | self.initial_params[self.sort_type_param_name] = sort_type
130 | return '?%s' % self.initial_params.urlencode()
131 |
132 | def get_sort(self):
133 | if not self.initial_sort:
134 | return None
135 | sort = '%s' % self.initial_sort
136 | if self.initial_sort_type == 'desc':
137 | sort = '-%s' % sort
138 | return sort
139 |
140 |
141 | class SortableListMixin(ContextMixin):
142 | """
143 | You can provide either sort_fields as a plain list like ['id', 'some', 'foo__bar', ...]
144 | or, if you want to hide original field names you can provide list of tuples with aliace that will be used:
145 | [('id', 'by_id'), ('some', 'show_this'), ('foo__bar', 'bar')]
146 |
147 | If sort_param_name exists in query but sort_type_param_name is omitted queryset will be sorted as 'asc'
148 | """
149 | sort_fields = []
150 | sort_fields_aliases = []
151 | sort_param_name = 'o'
152 | sort_type_param_name = 'ot'
153 |
154 | def get_sort_fields(self):
155 | if self.sort_fields:
156 | return zip(self.sort_fields, self.sort_fields)
157 | return self.sort_fields_aliases
158 |
159 | def get_sort_helper(self):
160 | return SortHelper(self.request, self.get_sort_fields(), self.sort_param_name, self.sort_type_param_name)
161 |
162 | def _sort_queryset(self, queryset):
163 | self.sort_helper = self.get_sort_helper()
164 | sort = self.sort_helper.get_sort()
165 | if sort:
166 | queryset = queryset.order_by(sort)
167 | return queryset
168 |
169 | def get_queryset(self):
170 | qs = super(SortableListMixin, self).get_queryset()
171 | if self.sort_fields and self.sort_fields_aliases:
172 | raise ImproperlyConfigured('You should provide sort_fields or sort_fields_aliaces but not both')
173 | return self._sort_queryset(qs)
174 |
175 | def get_context_data(self, **kwargs):
176 | context = {}
177 | if hasattr(self, 'sort_helper'):
178 | context['sort_helper'] = self.sort_helper
179 | context.update(kwargs)
180 | return super(SortableListMixin, self).get_context_data(**context)
181 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Django Extra Views documentation build configuration file, created by
4 | # sphinx-quickstart on Sun Jan 6 03:11:50 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, os
15 |
16 | # If extensions (or modules to document with autodoc) are in another directory,
17 | # add these directories to sys.path here. If the directory is relative to the
18 | # documentation root, use os.path.abspath to make it absolute, like shown here.
19 | #sys.path.insert(0, os.path.abspath('.'))
20 |
21 | # -- General configuration -----------------------------------------------------
22 |
23 | # If your documentation needs a minimal Sphinx version, state it here.
24 | #needs_sphinx = '1.0'
25 |
26 | # Add any Sphinx extension module names here, as strings. They can be extensions
27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx']
29 |
30 | # Add any paths that contain templates here, relative to this directory.
31 | templates_path = ['_templates']
32 |
33 | # The suffix of source filenames.
34 | source_suffix = '.rst'
35 |
36 | # The encoding of source files.
37 | #source_encoding = 'utf-8-sig'
38 |
39 | # The master toctree document.
40 | master_doc = 'index'
41 |
42 | # General information about the project.
43 | project = u'Django Extra Views'
44 | copyright = u'2013, Andrew Ingram'
45 |
46 | # The version info for the project you're documenting, acts as replacement for
47 | # |version| and |release|, also used in various other places throughout the
48 | # built documents.
49 | #
50 | # The short X.Y version.
51 | version = '0.5.4'
52 | # The full version, including alpha/beta/rc tags.
53 | release = '0.5.4'
54 |
55 | # The language for content autogenerated by Sphinx. Refer to documentation
56 | # for a list of supported languages.
57 | #language = None
58 |
59 | # There are two options for replacing |today|: either, you set today to some
60 | # non-false value, then it is used:
61 | #today = ''
62 | # Else, today_fmt is used as the format for a strftime call.
63 | #today_fmt = '%B %d, %Y'
64 |
65 | # List of patterns, relative to source directory, that match files and
66 | # directories to ignore when looking for source files.
67 | exclude_patterns = ['_build']
68 |
69 | # The reST default role (used for this markup: `text`) to use for all documents.
70 | #default_role = None
71 |
72 | # If true, '()' will be appended to :func: etc. cross-reference text.
73 | #add_function_parentheses = True
74 |
75 | # If true, the current module name will be prepended to all description
76 | # unit titles (such as .. function::).
77 | #add_module_names = True
78 |
79 | # If true, sectionauthor and moduleauthor directives will be shown in the
80 | # output. They are ignored by default.
81 | #show_authors = False
82 |
83 | # The name of the Pygments (syntax highlighting) style to use.
84 | pygments_style = 'sphinx'
85 |
86 | # A list of ignored prefixes for module index sorting.
87 | #modindex_common_prefix = []
88 |
89 |
90 | # -- Options for HTML output ---------------------------------------------------
91 |
92 | # The theme to use for HTML and HTML Help pages. See the documentation for
93 | # a list of builtin themes.
94 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
95 | if on_rtd:
96 | html_theme = 'default'
97 | else:
98 | html_theme = 'nature'
99 |
100 | # Theme options are theme-specific and customize the look and feel of a theme
101 | # further. For a list of options available for each theme, see the
102 | # documentation.
103 | #html_theme_options = {}
104 |
105 | # Add any paths that contain custom themes here, relative to this directory.
106 | #html_theme_path = []
107 |
108 | # The name for this set of Sphinx documents. If None, it defaults to
109 | # " v documentation".
110 | #html_title = None
111 |
112 | # A shorter title for the navigation bar. Default is the same as html_title.
113 | #html_short_title = None
114 |
115 | # The name of an image file (relative to this directory) to place at the top
116 | # of the sidebar.
117 | #html_logo = None
118 |
119 | # The name of an image file (within the static path) to use as favicon of the
120 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
121 | # pixels large.
122 | #html_favicon = None
123 |
124 | # Add any paths that contain custom static files (such as style sheets) here,
125 | # relative to this directory. They are copied after the builtin static files,
126 | # so a file named "default.css" will overwrite the builtin "default.css".
127 | html_static_path = ['_static']
128 |
129 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
130 | # using the given strftime format.
131 | #html_last_updated_fmt = '%b %d, %Y'
132 |
133 | # If true, SmartyPants will be used to convert quotes and dashes to
134 | # typographically correct entities.
135 | #html_use_smartypants = True
136 |
137 | # Custom sidebar templates, maps document names to template names.
138 | #html_sidebars = {}
139 |
140 | # Additional templates that should be rendered to pages, maps page names to
141 | # template names.
142 | #html_additional_pages = {}
143 |
144 | # If false, no module index is generated.
145 | #html_domain_indices = True
146 |
147 | # If false, no index is generated.
148 | #html_use_index = True
149 |
150 | # If true, the index is split into individual pages for each letter.
151 | #html_split_index = False
152 |
153 | # If true, links to the reST sources are added to the pages.
154 | #html_show_sourcelink = True
155 |
156 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
157 | #html_show_sphinx = True
158 |
159 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
160 | #html_show_copyright = True
161 |
162 | # If true, an OpenSearch description file will be output, and all pages will
163 | # contain a tag referring to it. The value of this option must be the
164 | # base URL from which the finished HTML is served.
165 | #html_use_opensearch = ''
166 |
167 | # This is the file name suffix for HTML files (e.g. ".xhtml").
168 | #html_file_suffix = None
169 |
170 | # Output file base name for HTML help builder.
171 | htmlhelp_basename = 'DjangoExtraViewsdoc'
172 |
173 |
174 | # -- Options for LaTeX output --------------------------------------------------
175 |
176 | latex_elements = {
177 | # The paper size ('letterpaper' or 'a4paper').
178 | #'papersize': 'letterpaper',
179 |
180 | # The font size ('10pt', '11pt' or '12pt').
181 | #'pointsize': '10pt',
182 |
183 | # Additional stuff for the LaTeX preamble.
184 | #'preamble': '',
185 | }
186 |
187 | # Grouping the document tree into LaTeX files. List of tuples
188 | # (source start file, target name, title, author, documentclass [howto/manual]).
189 | latex_documents = [
190 | ('index', 'DjangoExtraViews.tex', u'Django Extra Views Documentation',
191 | u'Andrew Ingram', 'manual'),
192 | ]
193 |
194 | # The name of an image file (relative to this directory) to place at the top of
195 | # the title page.
196 | #latex_logo = None
197 |
198 | # For "manual" documents, if this is true, then toplevel headings are parts,
199 | # not chapters.
200 | #latex_use_parts = False
201 |
202 | # If true, show page references after internal links.
203 | #latex_show_pagerefs = False
204 |
205 | # If true, show URL addresses after external links.
206 | #latex_show_urls = False
207 |
208 | # Documents to append as an appendix to all manuals.
209 | #latex_appendices = []
210 |
211 | # If false, no module index is generated.
212 | #latex_domain_indices = True
213 |
214 |
215 | # -- Options for manual page output --------------------------------------------
216 |
217 | # One entry per manual page. List of tuples
218 | # (source start file, name, description, authors, manual section).
219 | man_pages = [
220 | ('index', 'djangoextraviews', u'Django Extra Views Documentation',
221 | [u'Andrew Ingram'], 1)
222 | ]
223 |
224 | # If true, show URL addresses after external links.
225 | #man_show_urls = False
226 |
227 |
228 | # -- Options for Texinfo output ------------------------------------------------
229 |
230 | # Grouping the document tree into Texinfo files. List of tuples
231 | # (source start file, target name, title, author,
232 | # dir menu entry, description, category)
233 | texinfo_documents = [
234 | ('index', 'DjangoExtraViews', u'Django Extra Views Documentation',
235 | u'Andrew Ingram', 'DjangoExtraViews', 'One line description of project.',
236 | 'Miscellaneous'),
237 | ]
238 |
239 | # Documents to append as an appendix to all manuals.
240 | #texinfo_appendices = []
241 |
242 | # If false, no module index is generated.
243 | #texinfo_domain_indices = True
244 |
245 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
246 | #texinfo_show_urls = 'footnote'
247 |
248 |
249 | # Example configuration for intersphinx: refer to the Python standard library.
250 | intersphinx_mapping = {'http://docs.python.org/': None}
251 |
--------------------------------------------------------------------------------
/extra_views/dates.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from calendar import Calendar
4 | from collections import defaultdict
5 | import datetime
6 | import math
7 |
8 | from django.views.generic.dates import DateMixin, YearMixin, MonthMixin, _date_from_string
9 | from django.views.generic.list import MultipleObjectTemplateResponseMixin, BaseListView
10 | from django.db. models import Q
11 | from django.core.exceptions import ImproperlyConfigured
12 | from django.utils.translation import ugettext_lazy as _
13 |
14 | DAYS = (
15 | _('Monday'),
16 | _('Tuesday'),
17 | _('Wednesday'),
18 | _('Thursday'),
19 | _('Friday'),
20 | _('Saturday'),
21 | _('Sunday'),
22 | )
23 |
24 |
25 | def daterange(start_date, end_date):
26 | """
27 | Returns an iterator of dates between two provided ones
28 | """
29 | for n in range(int((end_date - start_date).days + 1)):
30 | yield start_date + datetime.timedelta(n)
31 |
32 |
33 | class BaseCalendarMonthView(DateMixin, YearMixin, MonthMixin, BaseListView):
34 | """
35 | A base view for displaying a calendar month
36 | """
37 | first_of_week = 0 # 0 = Monday, 6 = Sunday
38 | paginate_by = None # We don't want to use this part of MultipleObjectMixin
39 | date_field = None
40 | end_date_field = None # For supporting events with duration
41 |
42 | def get_paginate_by(self, queryset):
43 | if self.paginate_by is not None:
44 | raise ImproperlyConfigured("'%s' cannot be paginated, it is a calendar view" % self.__class__.__name__)
45 | return None
46 |
47 | def get_allow_future(self):
48 | return True
49 |
50 | def get_end_date_field(self):
51 | """
52 | Returns the model field to use for end dates
53 | """
54 | return self.end_date_field
55 |
56 | def get_start_date(self, obj):
57 | """
58 | Returns the start date for a model instance
59 | """
60 | obj_date = getattr(obj, self.get_date_field())
61 | try:
62 | obj_date = obj_date.date()
63 | except AttributeError:
64 | # It's a date rather than datetime, so we use it as is
65 | pass
66 | return obj_date
67 |
68 | def get_end_date(self, obj):
69 | """
70 | Returns the end date for a model instance
71 | """
72 | obj_date = getattr(obj, self.get_end_date_field())
73 | try:
74 | obj_date = obj_date.date()
75 | except AttributeError:
76 | # It's a date rather than datetime, so we use it as is
77 | pass
78 | return obj_date
79 |
80 | def get_first_of_week(self):
81 | """
82 | Returns an integer representing the first day of the week.
83 |
84 | 0 represents Monday, 6 represents Sunday.
85 | """
86 | if self.first_of_week is None:
87 | raise ImproperlyConfigured("%s.first_of_week is required." % self.__class__.__name__)
88 | if self.first_of_week not in range(7):
89 | raise ImproperlyConfigured("%s.first_of_week must be an integer between 0 and 6." % self.__class__.__name__)
90 | return self.first_of_week
91 |
92 | def get_queryset(self):
93 | """
94 | Returns a queryset of models for the month requested
95 | """
96 | qs = super(BaseCalendarMonthView, self).get_queryset()
97 |
98 | year = self.get_year()
99 | month = self.get_month()
100 |
101 | date_field = self.get_date_field()
102 | end_date_field = self.get_end_date_field()
103 |
104 | date = _date_from_string(year, self.get_year_format(),
105 | month, self.get_month_format())
106 |
107 | since = date
108 | until = self.get_next_month(date)
109 |
110 | # Adjust our start and end dates to allow for next and previous
111 | # month edges
112 | if since.weekday() != self.get_first_of_week():
113 | diff = math.fabs(since.weekday() - self.get_first_of_week())
114 | since = since - datetime.timedelta(days=diff)
115 |
116 | if until.weekday() != ((self.get_first_of_week() + 6) % 7):
117 | diff = math.fabs(((self.get_first_of_week() + 6) % 7) - until.weekday())
118 | until = until + datetime.timedelta(days=diff)
119 |
120 | if end_date_field:
121 | # 5 possible conditions for showing an event:
122 |
123 | # 1) Single day event, starts after 'since'
124 | # 2) Multi-day event, starts after 'since' and ends before 'until'
125 | # 3) Starts before 'since' and ends after 'since' and before 'until'
126 | # 4) Starts after 'since' but before 'until' and ends after 'until'
127 | # 5) Starts before 'since' and ends after 'until'
128 | predicate1 = Q(**{
129 | '%s__gte' % date_field: since,
130 | end_date_field: None
131 | })
132 | predicate2 = Q(**{
133 | '%s__gte' % date_field: since,
134 | '%s__lt' % end_date_field: until
135 | })
136 | predicate3 = Q(**{
137 | '%s__lt' % date_field: since,
138 | '%s__gte' % end_date_field: since,
139 | '%s__lt' % end_date_field: until
140 | })
141 | predicate4 = Q(**{
142 | '%s__gte' % date_field: since,
143 | '%s__lt' % date_field: until,
144 | '%s__gte' % end_date_field: until
145 | })
146 | predicate5 = Q(**{
147 | '%s__lt' % date_field: since,
148 | '%s__gte' % end_date_field: until
149 | })
150 | return qs.filter(predicate1 | predicate2 | predicate3 | predicate4 | predicate5)
151 | return qs.filter(**{
152 | '%s__gte' % date_field: since
153 | })
154 |
155 | def get_context_data(self, **kwargs):
156 | """
157 | Injects variables necessary for rendering the calendar into the context.
158 |
159 | Variables added are: `calendar`, `weekdays`, `month`, `next_month` and `previous_month`.
160 | """
161 | data = super(BaseCalendarMonthView, self).get_context_data(**kwargs)
162 |
163 | year = self.get_year()
164 | month = self.get_month()
165 |
166 | date = _date_from_string(year, self.get_year_format(),
167 | month, self.get_month_format())
168 |
169 | cal = Calendar(self.get_first_of_week())
170 |
171 | month_calendar = []
172 | now = datetime.datetime.utcnow()
173 |
174 | date_lists = defaultdict(list)
175 | multidate_objs = []
176 |
177 | for obj in data['object_list']:
178 | obj_date = self.get_start_date(obj)
179 | end_date_field = self.get_end_date_field()
180 |
181 | if end_date_field:
182 | end_date = self.get_end_date(obj)
183 | if end_date and end_date != obj_date:
184 | multidate_objs.append({
185 | 'obj': obj,
186 | 'range': [x for x in daterange(obj_date, end_date)]
187 | })
188 | continue # We don't put multi-day events in date_lists
189 | date_lists[obj_date].append(obj)
190 |
191 | for week in cal.monthdatescalendar(date.year, date.month):
192 | week_range = set(daterange(week[0], week[6]))
193 | week_events = []
194 |
195 | for val in multidate_objs:
196 | intersect_length = len(week_range.intersection(val['range']))
197 |
198 | if intersect_length:
199 | # Event happens during this week
200 | slot = 1
201 | width = intersect_length # How many days is the event during this week?
202 | nowrap_previous = True # Does the event continue from the previous week?
203 | nowrap_next = True # Does the event continue to the next week?
204 |
205 | if val['range'][0] >= week[0]:
206 | slot = 1 + (val['range'][0] - week[0]).days
207 | else:
208 | nowrap_previous = False
209 | if val['range'][-1] > week[6]:
210 | nowrap_next = False
211 |
212 | week_events.append({
213 | 'event': val['obj'],
214 | 'slot': slot,
215 | 'width': width,
216 | 'nowrap_previous': nowrap_previous,
217 | 'nowrap_next': nowrap_next,
218 | })
219 |
220 | week_calendar = {
221 | 'events': week_events,
222 | 'date_list': [],
223 | }
224 | for day in week:
225 | week_calendar['date_list'].append({
226 | 'day': day,
227 | 'events': date_lists[day],
228 | 'today': day == now.date(),
229 | 'is_current_month': day.month == date.month,
230 | })
231 | month_calendar.append(week_calendar)
232 |
233 | data['calendar'] = month_calendar
234 | data['weekdays'] = [DAYS[x] for x in cal.iterweekdays()]
235 | data['month'] = date
236 | data['next_month'] = self.get_next_month(date)
237 | data['previous_month'] = self.get_previous_month(date)
238 |
239 | return data
240 |
241 |
242 | class CalendarMonthView(MultipleObjectTemplateResponseMixin, BaseCalendarMonthView):
243 | """
244 | A view for displaying a calendar month, and rendering a template response
245 | """
246 | template_name_suffix = '_calendar_month'
247 |
--------------------------------------------------------------------------------
/extra_views/formsets.py:
--------------------------------------------------------------------------------
1 | import django
2 | from django.views.generic.base import TemplateResponseMixin, View, ContextMixin
3 | from django.http import HttpResponseRedirect
4 | from django.forms.formsets import formset_factory
5 | from django.forms.models import modelformset_factory, inlineformset_factory
6 | from django.views.generic.detail import SingleObjectMixin, SingleObjectTemplateResponseMixin
7 | from django.views.generic.list import MultipleObjectMixin, MultipleObjectTemplateResponseMixin
8 | from django.forms.models import BaseInlineFormSet
9 | from django.utils.functional import curry
10 |
11 |
12 | class BaseFormSetMixin(object):
13 | """
14 | Base class for constructing a FormSet within a view
15 | """
16 |
17 | initial = []
18 | form_class = None
19 | formset_class = None
20 | success_url = None
21 | extra = 2
22 | max_num = None
23 | can_order = False
24 | can_delete = False
25 | prefix = None
26 |
27 | def construct_formset(self):
28 | """
29 | Returns an instance of the formset
30 | """
31 | formset_class = self.get_formset()
32 | extra_form_kwargs = self.get_extra_form_kwargs()
33 |
34 | # Hack to let as pass additional kwargs to each forms constructor. Be aware that this
35 | # doesn't let us provide *different* arguments for each form
36 | if extra_form_kwargs:
37 | formset_class.form = staticmethod(curry(formset_class.form, **extra_form_kwargs))
38 |
39 | return formset_class(**self.get_formset_kwargs())
40 |
41 | def get_initial(self):
42 | """
43 | Returns the initial data to use for formsets on this view.
44 | """
45 | return self.initial
46 |
47 | def get_formset_class(self):
48 | """
49 | Returns the formset class to use in the formset factory
50 | """
51 | return self.formset_class
52 |
53 | def get_extra_form_kwargs(self):
54 | """
55 | Returns extra keyword arguments to pass to each form in the formset
56 | """
57 | return {}
58 |
59 | def get_form_class(self):
60 | """
61 | Returns the form class to use with the formset in this view
62 | """
63 | return self.form_class
64 |
65 | def get_formset(self):
66 | """
67 | Returns the formset class from the formset factory
68 | """
69 | return formset_factory(self.get_form_class(), **self.get_factory_kwargs())
70 |
71 | def get_formset_kwargs(self):
72 | """
73 | Returns the keyword arguments for instantiating the formset.
74 | """
75 | kwargs = {}
76 |
77 | # We have to check whether initial has been set rather than blindly passing it along,
78 | # This is because Django 1.3 doesn't let inline formsets accept initial, and no versions
79 | # of Django let generic inline formset handle initial data.
80 | initial = self.get_initial()
81 | if initial:
82 | kwargs['initial'] = initial
83 |
84 | if self.prefix:
85 | kwargs['prefix'] = self.prefix
86 |
87 | if self.request.method in ('POST', 'PUT'):
88 | kwargs.update({
89 | 'data': self.request.POST,
90 | 'files': self.request.FILES,
91 | })
92 | return kwargs
93 |
94 | def get_factory_kwargs(self):
95 | """
96 | Returns the keyword arguments for calling the formset factory
97 | """
98 | kwargs = {
99 | 'extra': self.extra,
100 | 'max_num': self.max_num,
101 | 'can_order': self.can_order,
102 | 'can_delete': self.can_delete
103 | }
104 |
105 | if self.get_formset_class():
106 | kwargs['formset'] = self.get_formset_class()
107 |
108 | return kwargs
109 |
110 |
111 | class FormSetMixin(BaseFormSetMixin, ContextMixin):
112 | """
113 | A mixin that provides a way to show and handle a formset in a request.
114 | """
115 |
116 | def get_success_url(self):
117 | """
118 | Returns the supplied URL.
119 | """
120 | if self.success_url:
121 | url = self.success_url
122 | else:
123 | # Default to returning to the same page
124 | url = self.request.get_full_path()
125 | return url
126 |
127 | def formset_valid(self, formset):
128 | """
129 | If the formset is valid redirect to the supplied URL
130 | """
131 | return HttpResponseRedirect(self.get_success_url())
132 |
133 | def formset_invalid(self, formset):
134 | """
135 | If the formset is invalid, re-render the context data with the
136 | data-filled formset and errors.
137 | """
138 | return self.render_to_response(self.get_context_data(formset=formset))
139 |
140 |
141 | class ModelFormSetMixin(FormSetMixin, MultipleObjectMixin):
142 | """
143 | A mixin that provides a way to show and handle a model formset in a request.
144 | """
145 |
146 | exclude = None
147 | fields = None
148 | formfield_callback = None
149 | widgets = None
150 |
151 | def get_context_data(self, **kwargs):
152 | """
153 | If an object list has been supplied, inject it into the context with the
154 | supplied context_object_name name.
155 | """
156 | context = {}
157 |
158 | if self.object_list:
159 | context['object_list'] = self.object_list
160 | context_object_name = self.get_context_object_name(self.object_list)
161 | if context_object_name:
162 | context[context_object_name] = self.object_list
163 | context.update(kwargs)
164 |
165 | # MultipleObjectMixin get_context_data() doesn't work when object_list
166 | # is not provided in kwargs, so we skip MultipleObjectMixin and call
167 | # ContextMixin directly.
168 | return ContextMixin.get_context_data(self, **context)
169 |
170 | def get_formset_kwargs(self):
171 | """
172 | Returns the keyword arguments for instantiating the formset.
173 | """
174 | kwargs = super(ModelFormSetMixin, self).get_formset_kwargs()
175 | kwargs['queryset'] = self.get_queryset()
176 | return kwargs
177 |
178 | def get_factory_kwargs(self):
179 | """
180 | Returns the keyword arguments for calling the formset factory
181 | """
182 | kwargs = super(ModelFormSetMixin, self).get_factory_kwargs()
183 | kwargs.update({
184 | 'exclude': self.exclude,
185 | 'fields': self.fields,
186 | 'formfield_callback': self.formfield_callback,
187 | 'widgets': self.widgets,
188 | })
189 | if self.get_form_class():
190 | kwargs['form'] = self.get_form_class()
191 | if self.get_formset_class():
192 | kwargs['formset'] = self.get_formset_class()
193 | return kwargs
194 |
195 | def get_formset(self):
196 | """
197 | Returns the formset class from the model formset factory
198 | """
199 | return modelformset_factory(self.model, **self.get_factory_kwargs())
200 |
201 | def formset_valid(self, formset):
202 | """
203 | If the formset is valid, save the associated models.
204 | """
205 | self.object_list = formset.save()
206 | return super(ModelFormSetMixin, self).formset_valid(formset)
207 |
208 |
209 | class BaseInlineFormSetMixin(BaseFormSetMixin):
210 | """
211 | Base class for constructing an inline formSet within a view
212 | """
213 | model = None
214 | inline_model = None
215 | fk_name = None
216 | formset_class = BaseInlineFormSet
217 | exclude = None
218 | fields = None
219 | formfield_callback = None
220 | can_delete = True
221 | save_as_new = False
222 |
223 | def get_context_data(self, **kwargs):
224 | """
225 | If an object has been supplied, inject it into the context with the
226 | supplied context_object_name name.
227 | """
228 | context = {}
229 |
230 | if self.object:
231 | context['object'] = self.object
232 | context_object_name = self.get_context_object_name(self.object)
233 | if context_object_name:
234 | context[context_object_name] = self.object
235 | context.update(kwargs)
236 | return super(BaseInlineFormSetMixin, self).get_context_data(**context)
237 |
238 | def get_inline_model(self):
239 | """
240 | Returns the inline model to use with the inline formset
241 | """
242 | return self.inline_model
243 |
244 | def get_formset_kwargs(self):
245 | """
246 | Returns the keyword arguments for instantiating the formset.
247 | """
248 | kwargs = super(BaseInlineFormSetMixin, self).get_formset_kwargs()
249 | kwargs['save_as_new'] = self.save_as_new
250 | kwargs['instance'] = self.object
251 | return kwargs
252 |
253 | def get_factory_kwargs(self):
254 | """
255 | Returns the keyword arguments for calling the formset factory
256 | """
257 | kwargs = super(BaseInlineFormSetMixin, self).get_factory_kwargs()
258 | kwargs.update({
259 | 'exclude': self.exclude,
260 | 'fields': self.fields,
261 | 'formfield_callback': self.formfield_callback,
262 | 'fk_name': self.fk_name,
263 | })
264 | if self.get_form_class():
265 | kwargs['form'] = self.get_form_class()
266 | if self.get_formset_class():
267 | kwargs['formset'] = self.get_formset_class()
268 | return kwargs
269 |
270 | def get_formset(self):
271 | """
272 | Returns the formset class from the inline formset factory
273 | """
274 | return inlineformset_factory(self.model, self.get_inline_model(), **self.get_factory_kwargs())
275 |
276 |
277 | class InlineFormSetMixin(BaseInlineFormSetMixin, FormSetMixin, SingleObjectMixin):
278 | """
279 | A mixin that provides a way to show and handle a inline formset in a request.
280 | """
281 |
282 | def formset_valid(self, formset):
283 | self.object_list = formset.save()
284 | return super(InlineFormSetMixin, self).formset_valid(formset)
285 |
286 |
287 | class ProcessFormSetView(View):
288 | """
289 | A mixin that processes a formset on POST.
290 | """
291 |
292 | def get(self, request, *args, **kwargs):
293 | """
294 | Handles GET requests and instantiates a blank version of the formset.
295 | """
296 | formset = self.construct_formset()
297 | return self.render_to_response(self.get_context_data(formset=formset))
298 |
299 | def post(self, request, *args, **kwargs):
300 | """
301 | Handles POST requests, instantiating a formset instance with the passed
302 | POST variables and then checked for validity.
303 | """
304 | formset = self.construct_formset()
305 | if formset.is_valid():
306 | return self.formset_valid(formset)
307 | else:
308 | return self.formset_invalid(formset)
309 |
310 | # PUT is a valid HTTP verb for creating (with a known URL) or editing an
311 | # object, note that browsers only support POST for now.
312 | def put(self, *args, **kwargs):
313 | return self.post(*args, **kwargs)
314 |
315 |
316 | class BaseFormSetView(FormSetMixin, ProcessFormSetView):
317 | """
318 | A base view for displaying a formset
319 | """
320 |
321 |
322 | class FormSetView(TemplateResponseMixin, BaseFormSetView):
323 | """
324 | A view for displaying a formset, and rendering a template response
325 | """
326 |
327 |
328 | class BaseModelFormSetView(ModelFormSetMixin, ProcessFormSetView):
329 | """
330 | A base view for displaying a model formset
331 | """
332 | def get(self, request, *args, **kwargs):
333 | self.object_list = self.get_queryset()
334 | return super(BaseModelFormSetView, self).get(request, *args, **kwargs)
335 |
336 | def post(self, request, *args, **kwargs):
337 | self.object_list = self.get_queryset()
338 | return super(BaseModelFormSetView, self).post(request, *args, **kwargs)
339 |
340 |
341 | class ModelFormSetView(MultipleObjectTemplateResponseMixin, BaseModelFormSetView):
342 | """
343 | A view for displaying a model formset, and rendering a template response
344 | """
345 |
346 |
347 | class BaseInlineFormSetView(InlineFormSetMixin, ProcessFormSetView):
348 | """
349 | A base view for displaying an inline formset for a queryset belonging to a parent model
350 | """
351 | def get(self, request, *args, **kwargs):
352 | self.object = self.get_object()
353 | return super(BaseInlineFormSetView, self).get(request, *args, **kwargs)
354 |
355 | def post(self, request, *args, **kwargs):
356 | self.object = self.get_object()
357 | return super(BaseInlineFormSetView, self).post(request, *args, **kwargs)
358 |
359 |
360 | class InlineFormSetView(SingleObjectTemplateResponseMixin, BaseInlineFormSetView):
361 | """
362 | A view for displaying an inline formset for a queryset belonging to a parent model
363 | """
364 |
--------------------------------------------------------------------------------
/extra_views_tests/tests.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import datetime
4 | from decimal import Decimal as D
5 |
6 | import django
7 | from django.core.exceptions import ImproperlyConfigured
8 | from django.forms import ValidationError
9 | from django.test import TestCase
10 |
11 | if django.VERSION < (1, 8):
12 | from django.utils.unittest import expectedFailure
13 | else:
14 | from unittest import expectedFailure
15 |
16 | from .models import Item, Order, Tag, Event
17 |
18 |
19 | class FormSetViewTests(TestCase):
20 | management_data = {
21 | 'form-TOTAL_FORMS': '2',
22 | 'form-INITIAL_FORMS': '0',
23 | 'form-MAX_NUM_FORMS': '',
24 | }
25 |
26 | def test_create(self):
27 | res = self.client.get('/formset/simple/')
28 | self.assertEqual(res.status_code, 200)
29 | self.assertTrue('formset' in res.context)
30 | self.assertFalse('form' in res.context)
31 | self.assertTemplateUsed(res, 'extra_views/address_formset.html')
32 | self.assertEqual(res.context['formset'].__class__.__name__, 'AddressFormFormSet')
33 |
34 | def test_formset_named(self):
35 | res = self.client.get('/formset/simple/named/')
36 | self.assertEqual(res.status_code, 200)
37 | self.assertEqual(res.context['formset'], res.context['AddressFormset'])
38 |
39 | def test_missing_management(self):
40 | with self.assertRaises(ValidationError):
41 | self.client.post('/formset/simple/', {})
42 |
43 | def test_success(self):
44 | res = self.client.post('/formset/simple/', self.management_data, follow=True)
45 | self.assertRedirects(res, '/formset/simple/', status_code=302)
46 |
47 | @expectedFailure
48 | def test_put(self):
49 | res = self.client.put('/formset/simple/', self.management_data, follow=True)
50 | self.assertRedirects(res, '/formset/simple/', status_code=302)
51 |
52 | def test_success_url(self):
53 | res = self.client.post('/formset/simple_redirect/', self.management_data, follow=True)
54 | self.assertRedirects(res, '/formset/simple_redirect/valid/')
55 |
56 | def test_invalid(self):
57 | data = {
58 | 'form-0-name': 'Joe Bloggs',
59 | 'form-0-city': '',
60 | 'form-0-line1': '',
61 | 'form-0-line2': '',
62 | 'form-0-postcode': '',
63 | }
64 | data.update(self.management_data)
65 |
66 | res = self.client.post('/formset/simple/', data, follow=True)
67 | self.assertEqual(res.status_code, 200)
68 | self.assertTrue('postcode' in res.context['formset'].errors[0])
69 |
70 | def test_formset_class(self):
71 | res = self.client.get('/formset/custom/')
72 | self.assertEqual(res.status_code, 200)
73 |
74 |
75 | class ModelFormSetViewTests(TestCase):
76 | management_data = {
77 | 'form-TOTAL_FORMS': '2',
78 | 'form-INITIAL_FORMS': '0',
79 | 'form-MAX_NUM_FORMS': '',
80 | }
81 |
82 | def test_create(self):
83 | res = self.client.get('/modelformset/simple/')
84 | self.assertEqual(res.status_code, 200)
85 | self.assertTrue('formset' in res.context)
86 | self.assertFalse('form' in res.context)
87 | self.assertTemplateUsed(res, 'extra_views/item_formset.html')
88 | self.assertEqual(res.context['formset'].__class__.__name__, 'ItemFormFormSet')
89 |
90 | def test_override(self):
91 | res = self.client.get('/modelformset/custom/')
92 | self.assertEqual(res.status_code, 200)
93 | form = res.context['formset'].forms[0]
94 | self.assertEqual(form['flag'].value(), True)
95 | self.assertEqual(form['notes'].value(), 'Write notes here')
96 |
97 | def test_post(self):
98 | order = Order(name='Dummy Order')
99 | order.save()
100 |
101 | data = {
102 | 'form-0-name': 'Bubble Bath',
103 | 'form-0-sku': '1234567890123',
104 | 'form-0-price': D('9.99'),
105 | 'form-0-order': order.id,
106 | 'form-0-status': 0,
107 | }
108 | data.update(self.management_data)
109 | data['form-TOTAL_FORMS'] = '1'
110 | res = self.client.post('/modelformset/simple/', data, follow=True)
111 | self.assertEqual(res.status_code, 200)
112 | self.assertEqual(Item.objects.all().count(), 1)
113 |
114 | def test_context(self):
115 | order = Order(name='Dummy Order')
116 | order.save()
117 |
118 | for i in range(10):
119 | item = Item(name='Item %i' % i, sku=str(i) * 13, price=D('9.99'), order=order, status=0)
120 | item.save()
121 |
122 | res = self.client.get('/modelformset/simple/')
123 | self.assertTrue('object_list' in res.context)
124 | self.assertEqual(len(res.context['object_list']), 10)
125 |
126 |
127 | class InlineFormSetViewTests(TestCase):
128 | management_data = {
129 | 'items-TOTAL_FORMS': '2',
130 | 'items-INITIAL_FORMS': '0',
131 | 'items-MAX_NUM_FORMS': '',
132 | }
133 |
134 | def test_create(self):
135 | order = Order(name='Dummy Order')
136 | order.save()
137 |
138 | for i in range(10):
139 | item = Item(name='Item %i' % i, sku=str(i) * 13, price=D('9.99'), order=order, status=0)
140 | item.save()
141 |
142 | res = self.client.get('/inlineformset/{}/'.format(order.id))
143 |
144 | self.assertTrue('object' in res.context)
145 | self.assertTrue('order' in res.context)
146 |
147 | self.assertEqual(res.status_code, 200)
148 | self.assertTrue('formset' in res.context)
149 | self.assertFalse('form' in res.context)
150 |
151 | def test_post(self):
152 | order = Order(name='Dummy Order')
153 | order.save()
154 | data = {}
155 | data.update(self.management_data)
156 |
157 | res = self.client.post('/inlineformset/{}/'.format(order.id), data, follow=True)
158 | self.assertEqual(res.status_code, 200)
159 | self.assertTrue('formset' in res.context)
160 | self.assertFalse('form' in res.context)
161 |
162 | def test_save(self):
163 | order = Order(name='Dummy Order')
164 | order.save()
165 | data = {
166 | 'items-0-name': 'Bubble Bath',
167 | 'items-0-sku': '1234567890123',
168 | 'items-0-price': D('9.99'),
169 | 'items-0-status': 0,
170 | 'items-1-DELETE': True,
171 | }
172 | data.update(self.management_data)
173 |
174 | self.assertEqual(0, order.items.count())
175 | res = self.client.post('/inlineformset/{}/'.format(order.id), data, follow=True)
176 | order = Order.objects.get(id=order.id)
177 |
178 | context_instance = res.context['formset'][0].instance
179 |
180 | self.assertEqual('Bubble Bath', context_instance.name)
181 | self.assertEqual('', res.context['formset'][1].instance.name)
182 |
183 | self.assertEqual(1, order.items.count())
184 |
185 |
186 | class GenericInlineFormSetViewTests(TestCase):
187 | def test_get(self):
188 | order = Order(name='Dummy Order')
189 | order.save()
190 |
191 | order2 = Order(name='Other Order')
192 | order2.save()
193 |
194 | tag = Tag(name='Test', content_object=order)
195 | tag.save()
196 |
197 | tag = Tag(name='Test2', content_object=order2)
198 | tag.save()
199 |
200 | res = self.client.get('/genericinlineformset/{}/'.format(order.id))
201 |
202 | self.assertEqual(res.status_code, 200)
203 | self.assertTrue('formset' in res.context)
204 | self.assertFalse('form' in res.context)
205 | self.assertEqual('Test', res.context['formset'].forms[0]['name'].value())
206 |
207 | def test_post(self):
208 | order = Order(name='Dummy Order')
209 | order.save()
210 |
211 | tag = Tag(name='Test', content_object=order)
212 | tag.save()
213 |
214 | data = {
215 | 'extra_views_tests-tag-content_type-object_id-TOTAL_FORMS': 3,
216 | 'extra_views_tests-tag-content_type-object_id-INITIAL_FORMS': 1,
217 | 'extra_views_tests-tag-content_type-object_id-MAX_NUM_FORMS': '',
218 | 'extra_views_tests-tag-content_type-object_id-0-name': 'Updated',
219 | 'extra_views_tests-tag-content_type-object_id-0-id': 1,
220 | 'extra_views_tests-tag-content_type-object_id-1-DELETE': True,
221 | 'extra_views_tests-tag-content_type-object_id-2-DELETE': True,
222 | }
223 |
224 | res = self.client.post('/genericinlineformset/{}/'.format(order.id), data, follow=True)
225 | self.assertEqual(res.status_code, 200)
226 | self.assertEqual('Updated', res.context['formset'].forms[0]['name'].value())
227 | self.assertEqual(1, Tag.objects.count())
228 |
229 | def test_post2(self):
230 | order = Order(name='Dummy Order')
231 | order.save()
232 |
233 | tag = Tag(name='Test', content_object=order)
234 | tag.save()
235 |
236 | data = {
237 | 'extra_views_tests-tag-content_type-object_id-TOTAL_FORMS': 3,
238 | 'extra_views_tests-tag-content_type-object_id-INITIAL_FORMS': 1,
239 | 'extra_views_tests-tag-content_type-object_id-MAX_NUM_FORMS': '',
240 | 'extra_views_tests-tag-content_type-object_id-0-name': 'Updated',
241 | 'extra_views_tests-tag-content_type-object_id-0-id': tag.id,
242 | 'extra_views_tests-tag-content_type-object_id-1-name': 'Tag 2',
243 | 'extra_views_tests-tag-content_type-object_id-2-name': 'Tag 3',
244 | }
245 |
246 | res = self.client.post('/genericinlineformset/{}/'.format(order.id), data, follow=True)
247 | self.assertEqual(res.status_code, 200)
248 | self.assertEqual(3, Tag.objects.count())
249 |
250 |
251 | class ModelWithInlinesTests(TestCase):
252 | def test_create(self):
253 | res = self.client.get('/inlines/new/')
254 | self.assertEqual(res.status_code, 200)
255 | self.assertEqual(0, Tag.objects.count())
256 |
257 | data = {
258 | 'name': 'Dummy Order',
259 | 'items-TOTAL_FORMS': '2',
260 | 'items-INITIAL_FORMS': '0',
261 | 'items-MAX_NUM_FORMS': '',
262 | 'items-0-name': 'Bubble Bath',
263 | 'items-0-sku': '1234567890123',
264 | 'items-0-price': D('9.99'),
265 | 'items-0-status': 0,
266 | 'items-1-DELETE': True,
267 | 'extra_views_tests-tag-content_type-object_id-TOTAL_FORMS': 2,
268 | 'extra_views_tests-tag-content_type-object_id-INITIAL_FORMS': 0,
269 | 'extra_views_tests-tag-content_type-object_id-MAX_NUM_FORMS': '',
270 | 'extra_views_tests-tag-content_type-object_id-0-name': 'Test',
271 | 'extra_views_tests-tag-content_type-object_id-1-DELETE': True,
272 | }
273 |
274 | res = self.client.post('/inlines/new/', data, follow=True)
275 |
276 | self.assertTrue('object' in res.context)
277 | self.assertTrue('order' in res.context)
278 |
279 | self.assertEqual(res.status_code, 200)
280 | self.assertEqual(1, Tag.objects.count())
281 |
282 | def test_named_create(self):
283 | res = self.client.get('/inlines/new/named/')
284 | self.assertEqual(res.status_code, 200)
285 | self.assertEqual(res.context['Items'], res.context['inlines'][0])
286 | self.assertEqual(res.context['Tags'], res.context['inlines'][1])
287 |
288 | def test_validation(self):
289 | data = {
290 | 'items-TOTAL_FORMS': '2',
291 | 'items-INITIAL_FORMS': '0',
292 | 'items-MAX_NUM_FORMS': '',
293 | 'items-0-name': 'Test Item 1',
294 | 'items-0-sku': '',
295 | 'items-0-price': '',
296 | 'items-0-status': 0,
297 | 'items-1-name': '',
298 | 'items-1-sku': '',
299 | 'items-1-price': '',
300 | 'items-1-status': '',
301 | 'items-1-DELETE': True,
302 | 'extra_views_tests-tag-content_type-object_id-TOTAL_FORMS': 2,
303 | 'extra_views_tests-tag-content_type-object_id-INITIAL_FORMS': 0,
304 | 'extra_views_tests-tag-content_type-object_id-MAX_NUM_FORMS': '',
305 | 'extra_views_tests-tag-content_type-object_id-0-name': 'Test',
306 | 'extra_views_tests-tag-content_type-object_id-1-DELETE': True,
307 | }
308 |
309 | res = self.client.post('/inlines/new/', data, follow=True)
310 | self.assertEqual(len(res.context['form'].errors), 1)
311 | self.assertEqual(len(res.context['inlines'][0].errors[0]), 2)
312 |
313 | def test_update(self):
314 | order = Order(name='Dummy Order')
315 | order.save()
316 |
317 | item_ids = []
318 | for i in range(2):
319 | item = Item(name='Item %i' % i, sku=str(i) * 13, price=D('9.99'), order=order, status=0)
320 | item.save()
321 | item_ids.append(item.id)
322 |
323 | tag = Tag(name='Test', content_object=order)
324 | tag.save()
325 |
326 | res = self.client.get('/inlines/{}/'.format(order.id))
327 |
328 | self.assertEqual(res.status_code, 200)
329 | order = Order.objects.get(id=order.id)
330 |
331 | self.assertEqual(2, order.items.count())
332 | self.assertEqual('Item 0', order.items.all()[0].name)
333 |
334 | data = {
335 | 'name': 'Dummy Order',
336 | 'items-TOTAL_FORMS': '4',
337 | 'items-INITIAL_FORMS': '2',
338 | 'items-MAX_NUM_FORMS': '',
339 | 'items-0-name': 'Bubble Bath',
340 | 'items-0-sku': '1234567890123',
341 | 'items-0-price': D('9.99'),
342 | 'items-0-status': 0,
343 | 'items-0-id': item_ids[0],
344 | 'items-1-name': 'Bubble Bath',
345 | 'items-1-sku': '1234567890123',
346 | 'items-1-price': D('9.99'),
347 | 'items-1-status': 0,
348 | 'items-1-id': item_ids[1],
349 | 'items-2-name': 'Bubble Bath',
350 | 'items-2-sku': '1234567890123',
351 | 'items-2-price': D('9.99'),
352 | 'items-2-status': 0,
353 | 'items-3-DELETE': True,
354 | 'extra_views_tests-tag-content_type-object_id-TOTAL_FORMS': 3,
355 | 'extra_views_tests-tag-content_type-object_id-INITIAL_FORMS': 1,
356 | 'extra_views_tests-tag-content_type-object_id-MAX_NUM_FORMS': '',
357 | 'extra_views_tests-tag-content_type-object_id-0-name': 'Test',
358 | 'extra_views_tests-tag-content_type-object_id-0-id': tag.id,
359 | 'extra_views_tests-tag-content_type-object_id-0-DELETE': True,
360 | 'extra_views_tests-tag-content_type-object_id-1-name': 'Test 2',
361 | 'extra_views_tests-tag-content_type-object_id-2-name': 'Test 3',
362 | }
363 |
364 | res = self.client.post('/inlines/{}/'.format(order.id), data)
365 | self.assertEqual(res.status_code, 302)
366 |
367 | order = Order.objects.get(id=order.id)
368 |
369 | self.assertEqual(3, order.items.count())
370 | self.assertEqual(2, Tag.objects.count())
371 | self.assertEqual('Bubble Bath', order.items.all()[0].name)
372 |
373 | def test_parent_instance_saved_in_form_save(self):
374 | order = Order(name='Dummy Order')
375 | order.save()
376 |
377 | data = {
378 | 'name': 'Dummy Order',
379 | 'items-TOTAL_FORMS': '0',
380 | 'items-INITIAL_FORMS': '0',
381 | 'items-MAX_NUM_FORMS': '',
382 | 'extra_views_tests-tag-content_type-object_id-TOTAL_FORMS': '0',
383 | 'extra_views_tests-tag-content_type-object_id-INITIAL_FORMS': '0',
384 | 'extra_views_tests-tag-content_type-object_id-MAX_NUM_FORMS': '',
385 | }
386 |
387 | res = self.client.post('/inlines/{}/'.format(order.id), data)
388 | self.assertEqual(res.status_code, 302)
389 |
390 | order = Order.objects.get(id=order.id)
391 | self.assertTrue(order.action_on_save)
392 |
393 | def test_url_arg(self):
394 | """
395 | Regression test for #122: get_context_data should not be called with *args
396 | """
397 | res = self.client.get('/inlines/123/new/')
398 | self.assertEqual(res.status_code, 200)
399 |
400 |
401 | class CalendarViewTests(TestCase):
402 | def test_create(self):
403 | event = Event(name='Test Event', date=datetime.date(2012, 1, 1))
404 | event.save()
405 |
406 | res = self.client.get('/events/2012/jan/')
407 | self.assertEqual(res.status_code, 200)
408 |
409 |
410 | class SearchableListTests(TestCase):
411 | def setUp(self):
412 | order = Order(name='Dummy Order')
413 | order.save()
414 | Item.objects.create(sku='1A', name='test A', order=order, price=0, date_placed=datetime.date(2012, 1, 1))
415 | Item.objects.create(sku='1B', name='test B', order=order, price=0, date_placed=datetime.date(2012, 2, 1))
416 | Item.objects.create(sku='C', name='test', order=order, price=0, date_placed=datetime.date(2012, 3, 1))
417 |
418 | def test_search(self):
419 | res = self.client.get('/searchable/', data={'q': '1A test'})
420 | self.assertEqual(res.status_code, 200)
421 | self.assertEqual(1, len(res.context['object_list']))
422 |
423 | res = self.client.get('/searchable/', data={'q': '1Atest'})
424 | self.assertEqual(res.status_code, 200)
425 | self.assertEqual(0, len(res.context['object_list']))
426 |
427 | # date search
428 | res = self.client.get('/searchable/', data={'q': '01.01.2012'})
429 | self.assertEqual(res.status_code, 200)
430 | self.assertEqual(1, len(res.context['object_list']))
431 |
432 | res = self.client.get('/searchable/', data={'q': '02.01.2012'})
433 | self.assertEqual(res.status_code, 200)
434 | self.assertEqual(0, len(res.context['object_list']))
435 |
436 | # search query provided by view's get_search_query method
437 | res = self.client.get('/searchable/predefined_query/', data={'q': 'idoesntmatter'})
438 | self.assertEqual(res.status_code, 200)
439 | self.assertEqual(1, len(res.context['object_list']))
440 |
441 | # exact search query
442 | res = self.client.get('/searchable/exact_query/', data={'q': 'test'})
443 | self.assertEqual(res.status_code, 200)
444 | self.assertEqual(1, len(res.context['object_list']))
445 |
446 | # wrong lookup
447 | try:
448 | self.assertRaises(self.client.get('/searchable/wrong_lookup/', data={'q': 'test'}))
449 | error = False
450 | except ValueError:
451 | error = True
452 | self.assertTrue(error)
453 |
454 |
455 | class SortableViewTest(TestCase):
456 | def setUp(self):
457 | order = Order(name='Dummy Order')
458 | order.save()
459 | Item.objects.create(sku='1A', name='test A', order=order, price=0)
460 | Item.objects.create(sku='1B', name='test B', order=order, price=0)
461 |
462 | def test_sort(self):
463 | res = self.client.get('/sortable/fields/')
464 | self.assertEqual(res.status_code, 200)
465 | self.assertFalse(res.context['sort_helper'].is_sorted_by_name())
466 |
467 | asc_url = res.context['sort_helper'].get_sort_query_by_name_asc()
468 | res = self.client.get('/sortable/fields/%s' % asc_url)
469 | self.assertEqual(res.context['object_list'][0].name, 'test A')
470 | self.assertEqual(res.context['object_list'][1].name, 'test B')
471 | self.assertTrue(res.context['sort_helper'].is_sorted_by_name())
472 |
473 | desc_url = res.context['sort_helper'].get_sort_query_by_name_desc()
474 | res = self.client.get('/sortable/fields/%s' % desc_url)
475 | self.assertEqual(res.context['object_list'][0].name, 'test B')
476 | self.assertEqual(res.context['object_list'][1].name, 'test A')
477 | self.assertTrue(res.context['sort_helper'].is_sorted_by_name())
478 | # reversed sorting
479 | sort_url = res.context['sort_helper'].get_sort_query_by_name()
480 | res = self.client.get('/sortable/fields/%s' % sort_url)
481 | self.assertEqual(res.context['object_list'][0].name, 'test A')
482 | sort_url = res.context['sort_helper'].get_sort_query_by_name()
483 | res = self.client.get('/sortable/fields/%s' % sort_url)
484 | self.assertEqual(res.context['object_list'][0].name, 'test B')
485 | # can't use fields and aliases in same time
486 | self.assertRaises(ImproperlyConfigured, lambda: self.client.get('/sortable/fields_and_aliases/'))
487 | # check that aliases included in params
488 | res = self.client.get('/sortable/aliases/')
489 | self.assertIn('o=by_name', res.context['sort_helper'].get_sort_query_by_by_name())
490 | self.assertIn('o=by_sku', res.context['sort_helper'].get_sort_query_by_by_sku())
491 |
--------------------------------------------------------------------------------