diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5f428ed344..229e2a9e12 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -29,6 +29,7 @@ Changelog * Upgraded jQuery to version 3.2.1 (Janneke Janssen) * Updated documentation styling (LB (Ben Johnston)) * Rich text fields now take feature lists into account when whitelisting HTML elements (Matt Westcott) + * FormPage lists and Form submission lists in admin now use class based views for easy overriding (Johan Arensman) * Fix: Do not remove stopwords when generating slugs from non-ASCII titles, to avoid issues with incorrect word boundaries (Sævar Öfjörð Magnússon) * Fix: The PostgreSQL search backend now preserves ordering of the `QuerySet` when searching with `order_by_relevance=False` (Bertrand Bordage) * Fix: Using `modeladmin_register` as a decorator no longer replaces the decorated class with `None` (Tim Heap) diff --git a/docs/reference/contrib/forms/customisation.rst b/docs/reference/contrib/forms/customisation.rst index 7cfb9ae256..1a0f198bad 100644 --- a/docs/reference/contrib/forms/customisation.rst +++ b/docs/reference/contrib/forms/customisation.rst @@ -604,3 +604,46 @@ Finally, we add a URL param of `id` based on the ``form_submission`` if it exist FieldPanel('subject'), ], 'Email'), ] + + +Customise form submissions listing in Wagtail Admin +--------------------------------------------------- + +The Admin listing of form submissions can be customised by setting the attribute ``submissions_list_view_class`` on your FormPage model. + +The list view class must be a subclass of ``SubmissionsListView`` from ``wagtail.contrib.forms.views``, which is a child class of `Django's class based ListView `_. + +Example: + +.. code-block:: python + + from wagtail.contrib.forms.models import AbstractEmailForm, AbstractFormField + from wagtail.contrib.forms.views import SubmissionsListView + + + class CustomSubmissionsListView(SubmissionsListView): + paginate_by = 50 # show more submissions per page, default is 20 + ordering = ('submit_time',) # order submissions by oldest first, normally newest first + ordering_csv = ('-submit_time',) # order csv export by newest first, normally oldest first + + # override the method to generate csv filename + def get_csv_filename(self): + """ Returns the filename for CSV file with page title at start""" + filename = super().get_csv_filename() + return self.form_page.slug + '-' + filename + + + class FormField(AbstractFormField): + page = ParentalKey('FormPage', related_name='form_fields') + + + class FormPage(AbstractEmailForm): + """Form Page with customised submissions listing view""" + + # set custom view class as class attribute + submissions_list_view_class = CustomSubmissionsListView + + intro = RichTextField(blank=True) + thank_you_text = RichTextField(blank=True) + + # content_panels = ... diff --git a/docs/releases/2.0.rst b/docs/releases/2.0.rst index e1cd8e8641..eec7f1787e 100644 --- a/docs/releases/2.0.rst +++ b/docs/releases/2.0.rst @@ -42,6 +42,7 @@ Other features * Upgraded jQuery to version 3.2.1 (Janneke Janssen) * Updated documentation styling (LB (Ben Johnston)) * Rich text fields now take feature lists into account when whitelisting HTML elements (Matt Westcott) + * FormPage lists and Form submission lists in admin now use class based views for easy overriding (Johan Arensman) Bug fixes ~~~~~~~~~ diff --git a/wagtail/contrib/forms/models.py b/wagtail/contrib/forms/models.py index 852c99cc50..067b8bff5e 100644 --- a/wagtail/contrib/forms/models.py +++ b/wagtail/contrib/forms/models.py @@ -1,7 +1,6 @@ import json import os -from django.contrib.contenttypes.models import ContentType from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.shortcuts import render @@ -11,10 +10,12 @@ from unidecode import unidecode from wagtail.admin.edit_handlers import FieldPanel from wagtail.admin.utils import send_mail -from wagtail.core import hooks -from wagtail.core.models import Orderable, Page, UserPagePermissionsProxy, get_page_models +from wagtail.core.models import Orderable, Page from .forms import FormBuilder, WagtailAdminFormPageForm +from .views import SubmissionsListView + + FORM_FIELD_CHOICES = ( ('singleline', _('Single line text')), @@ -117,45 +118,16 @@ class AbstractFormField(Orderable): ordering = ['sort_order'] -_FORM_CONTENT_TYPES = None - - -def get_form_types(): - global _FORM_CONTENT_TYPES - if _FORM_CONTENT_TYPES is None: - form_models = [ - model for model in get_page_models() - if issubclass(model, AbstractForm) - ] - - _FORM_CONTENT_TYPES = list( - ContentType.objects.get_for_models(*form_models).values() - ) - return _FORM_CONTENT_TYPES - - -def get_forms_for_user(user): - """ - Return a queryset of form pages that this user is allowed to access the submissions for - """ - editable_forms = UserPagePermissionsProxy(user).editable_pages() - editable_forms = editable_forms.filter(content_type__in=get_form_types()) - - # Apply hooks - for fn in hooks.get_hooks('filter_form_submissions_for_user'): - editable_forms = fn(user, editable_forms) - - return editable_forms - - class AbstractForm(Page): """ A Form Page. Pages implementing a form should inherit from it """ + base_form_class = WagtailAdminFormPageForm + form_builder = FormBuilder - base_form_class = WagtailAdminFormPageForm + submissions_list_view_class = SubmissionsListView def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -207,33 +179,6 @@ class AbstractForm(Page): def get_landing_page_template(self, request, *args, **kwargs): return self.landing_page_template - def get_field_ordering(self, ordering_list, default=('submit_time', 'descending')): - """ - Accepts a list of strings ['-submit_time', 'id'] - Returns a list of tuples [(field_name, 'ascending'/'descending'), ...] - Intented to be used to process ordering via request.GET.getlist('order_by') - Checks if the field options are valid, only returns valid, de-duplicated options - invalid options are simply ignored - no error created - """ - valid_fields = ['id', 'submit_time'] - field_ordering = [] - if len(ordering_list) == 0: - return [default] - for ordering in ordering_list: - try: - none, prefix, field_name = ordering.rpartition('-') - if field_name not in valid_fields: - continue # Invalid field_name, skip it - # only add to ordering if the field is not already set - if field_name not in [order[0] for order in field_ordering]: - asc_desc = 'ascending' - if prefix == '-': - asc_desc = 'descending' - field_ordering.append((field_name, asc_desc)) - except (IndexError, ValueError): - continue # Invalid ordering specified, skip it - return field_ordering - def get_submission_class(self): """ Returns submission class. @@ -273,6 +218,16 @@ class AbstractForm(Page): context ) + def serve_submissions_list_view(self, request, *args, **kwargs): + """ + Returns list submissions view for admin. + + `list_submissions_view_class` can bse set to provide custom view class. + Your class must be inherited from SubmissionsListView. + """ + view = self.submissions_list_view_class.as_view() + return view(request, form_page=self, *args, **kwargs) + def serve(self, request, *args, **kwargs): if request.method == 'POST': form = self.get_form(request.POST, request.FILES, page=self, user=request.user) diff --git a/wagtail/contrib/forms/templates/wagtailforms/index_submissions.html b/wagtail/contrib/forms/templates/wagtailforms/index_submissions.html index 502b9132e2..1ab85666fb 100644 --- a/wagtail/contrib/forms/templates/wagtailforms/index_submissions.html +++ b/wagtail/contrib/forms/templates/wagtailforms/index_submissions.html @@ -114,8 +114,7 @@ {% if submissions %}
{% include "wagtailforms/list_submissions.html" %} - - {% include "wagtailadmin/shared/pagination_nav.html" with items=submissions is_searching=False %} + {% include "wagtailadmin/shared/pagination_nav.html" with items=page_obj is_searching=False linkurl='-' %} {# Here we pass an invalid non-empty URL name as linkurl to generate pagination links with the URL path omitted #}
{% else %} diff --git a/wagtail/contrib/forms/templates/wagtailforms/list_submissions.html b/wagtail/contrib/forms/templates/wagtailforms/list_submissions.html index 854b170cfb..ede108e0e0 100644 --- a/wagtail/contrib/forms/templates/wagtailforms/list_submissions.html +++ b/wagtail/contrib/forms/templates/wagtailforms/list_submissions.html @@ -6,13 +6,13 @@ - + - {% for heading in data_fields_with_ordering %} + {% for heading in data_headings %} {% if heading.order %}{{ heading.label }}{% else %}{{ heading.label }}{% endif %} diff --git a/wagtail/contrib/forms/templates/wagtailforms/results_forms.html b/wagtail/contrib/forms/templates/wagtailforms/results_forms.html index f6a889a367..4edcf8f43d 100644 --- a/wagtail/contrib/forms/templates/wagtailforms/results_forms.html +++ b/wagtail/contrib/forms/templates/wagtailforms/results_forms.html @@ -2,7 +2,7 @@ {% if form_pages %} {% include "wagtailforms/list_forms.html" %} - {% include "wagtailadmin/shared/pagination_nav.html" with items=form_pages linkurl="wagtailforms:index" %} + {% include "wagtailadmin/shared/pagination_nav.html" with items=page_obj linkurl="wagtailforms:index" %} {% else %}

{% trans "No form pages have been created." %}

{% endif %} diff --git a/wagtail/contrib/forms/tests/test_views.py b/wagtail/contrib/forms/tests/test_views.py index a522760e67..5113b6e72b 100644 --- a/wagtail/contrib/forms/tests/test_views.py +++ b/wagtail/contrib/forms/tests/test_views.py @@ -5,20 +5,22 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse -from wagtail.tests.testapp.models import ( - CustomFormPageSubmission, FormField, FormFieldWithCustomSubmission, - FormPage, FormPageWithCustomSubmission) -from wagtail.tests.utils import WagtailTestUtils from wagtail.admin.edit_handlers import get_form_for_model from wagtail.admin.forms import WagtailAdminPageForm -from wagtail.core.models import Page from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel from wagtail.contrib.forms.models import FormSubmission from wagtail.contrib.forms.tests.utils import make_form_page, make_form_page_with_custom_submission +from wagtail.core.models import Page +from wagtail.tests.testapp.models import ( + CustomFormPageSubmission, FormField, FormFieldForCustomListViewPage, + FormFieldWithCustomSubmission, FormPage, FormPageWithCustomSubmission, + FormPageWithCustomSubmissionListView) +from wagtail.tests.utils import WagtailTestUtils class TestFormResponsesPanel(TestCase): def setUp(self): + self.form_page = make_form_page() self.FormPageForm = get_form_for_model( @@ -133,7 +135,7 @@ class TestFormsIndex(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index.html') # Check that we got the correct page - self.assertEqual(response.context['form_pages'].number, 2) + self.assertEqual(response.context['page_obj'].number, 2) def test_forms_index_pagination_invalid(self): # Create some more form pages to make pagination kick in @@ -147,7 +149,7 @@ class TestFormsIndex(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index.html') # Check that it got page one - self.assertEqual(response.context['form_pages'].number, 1) + self.assertEqual(response.context['page_obj'].number, 1) def test_forms_index_pagination_out_of_range(self): # Create some more form pages to make pagination kick in @@ -161,7 +163,7 @@ class TestFormsIndex(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index.html') # Check that it got the last page - self.assertEqual(response.context['form_pages'].number, response.context['form_pages'].paginator.num_pages) + self.assertEqual(response.context['page_obj'].number, response.context['paginator'].num_pages) def test_cannot_see_forms_without_permission(self): # Login with as a user without permission to see forms @@ -315,7 +317,7 @@ class TestFormsSubmissionsList(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') # Check that we got the correct page - self.assertEqual(response.context['submissions'].number, 2) + self.assertEqual(response.context['page_obj'].number, 2) def test_list_submissions_pagination_invalid(self): self.make_list_submissions() @@ -329,7 +331,7 @@ class TestFormsSubmissionsList(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') # Check that we got page one - self.assertEqual(response.context['submissions'].number, 1) + self.assertEqual(response.context['page_obj'].number, 1) def test_list_submissions_pagination_out_of_range(self): self.make_list_submissions() @@ -341,7 +343,7 @@ class TestFormsSubmissionsList(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') # Check that we got the last page - self.assertEqual(response.context['submissions'].number, response.context['submissions'].paginator.num_pages) + self.assertEqual(response.context['page_obj'].number, response.context['paginator'].num_pages) def test_list_submissions_default_order(self): response = self.client.get(reverse( @@ -415,6 +417,28 @@ class TestFormsSubmissionsExport(TestCase, WagtailTestUtils): self.assertEqual(data_lines[1], '2013-01-01 12:00:00+00:00,old@example.com,this is a really old message,"foo, baz"\r') self.assertEqual(data_lines[2], '2014-01-01 12:00:00+00:00,new@example.com,this is a fairly new message,None\r') + def test_list_submissions_csv_large_export(self): + for i in range(100): + new_form_submission = FormSubmission.objects.create( + page=self.form_page, + form_data=json.dumps({ + 'your-email': "new@example-%s.com" % i, + 'your-message': "I like things x %s" % i, + }), + ) + new_form_submission.submit_time = '2014-01-01T12:00:00.000Z' + new_form_submission.save() + + response = self.client.get( + reverse('wagtailforms:list_submissions', args=(self.form_page.id,)), + {'action': 'CSV'} + ) + + # Check that csv export is not paginated + self.assertEqual(response.status_code, 200) + data_lines = response.content.decode().split("\n") + self.assertEqual(104, len(data_lines)) + def test_list_submissions_csv_export_after_filter_form_submissions_for_user_hook(self): # Hook forbids to delete form submissions for everyone def construct_forms_for_user(user, queryset): @@ -800,7 +824,7 @@ class TestCustomFormsSubmissionsList(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') # Check that we got the correct page - self.assertEqual(response.context['submissions'].number, 2) + self.assertEqual(response.context['page_obj'].number, 2) # CustomFormPageSubmission have custom field. This field should appear in the listing self.assertContains(response, 'Username', html=True) @@ -818,7 +842,7 @@ class TestCustomFormsSubmissionsList(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') # Check that we got page one - self.assertEqual(response.context['submissions'].number, 1) + self.assertEqual(response.context['page_obj'].number, 1) def test_list_submissions_pagination_out_of_range(self): self.make_list_submissions() @@ -831,8 +855,7 @@ class TestCustomFormsSubmissionsList(TestCase, WagtailTestUtils): self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') # Check that we got the last page - self.assertEqual(response.context['submissions'].number, - response.context['submissions'].paginator.num_pages) + self.assertEqual(response.context['page_obj'].number, response.context['paginator'].num_pages) class TestDeleteFormSubmission(TestCase, WagtailTestUtils): @@ -969,6 +992,146 @@ class TestDeleteCustomFormSubmission(TestCase): self.assertEqual(CustomFormPageSubmission.objects.count(), 2) +class TestFormsWithCustomSubmissionsList(TestCase, WagtailTestUtils): + + def create_test_user_without_admin(self, username): + return get_user_model().objects.create_user(username=username, password='123') + + def setUp(self): + # Create a form page + + home_page = Page.objects.get(url_path='/home/') + self.form_page = home_page.add_child( + instance=FormPageWithCustomSubmissionListView( + title='Willy Wonka Chocolate Ideas', + slug='willy-wonka-chocolate-ideas', + to_address='willy@wonka.com', + from_address='info@wonka.com', + subject='Chocolate Idea Submitted!' + ) + ) + FormFieldForCustomListViewPage.objects.create( + page=self.form_page, sort_order=1, label='Your email', field_type='email', required=True, + ) + FormFieldForCustomListViewPage.objects.create( + page=self.form_page, sort_order=2, label='Chocolate', field_type='singleline', required=True, + ) + FormFieldForCustomListViewPage.objects.create( + page=self.form_page, sort_order=3, label='Ingredients', field_type='multiline', required=True, + ) + self.choices = ['What is chocolate?', 'Mediocre', 'Much excitement', 'Wet my pants excited!'] + FormFieldForCustomListViewPage.objects.create( + page=self.form_page, sort_order=4, label='Your Excitement', field_type='radio', required=True, + choices=','.join(self.choices), + ) + + self.test_user_1 = self.create_test_user_without_admin('user-chocolate-maniac') + self.test_user_2 = self.create_test_user_without_admin('user-chocolate-guy') + + # add a couple of initial form submissions for testing ordering + new_form_submission = CustomFormPageSubmission.objects.create( + page=self.form_page, + user=self.test_user_1, + form_data=json.dumps({ + 'your-email': 'new@example.com', + 'chocolate': 'White Chocolate', + 'ingredients': 'White colouring', + 'your-excitement': self.choices[2], + }), + ) + new_form_submission.submit_time = '2017-10-01T12:00:00.000Z' + new_form_submission.save() + + old_form_submission = CustomFormPageSubmission.objects.create( + page=self.form_page, + user=self.test_user_2, + form_data=json.dumps({ + 'your-email': 'old@example.com', + 'chocolate': 'Dark Chocolate', + 'ingredients': 'Charcoal', + 'your-excitement': self.choices[0], + }), + ) + old_form_submission.submit_time = '2017-01-01T12:00:00.000Z' + old_form_submission.save() + + self.login() + + def make_list_submissions(self): + """ Make 100 submissions to test pagination on the forms submissions page """ + for i in range(120): + submission = CustomFormPageSubmission( + page=self.form_page, + user=self.test_user_1, + form_data=json.dumps({ + 'your-email': "foo-%s@bar.com" % i, + 'chocolate': 'Chocolate No.%s' % i, + 'your-excitement': self.choices[3], + }), + ) + submission.save() + + def test_list_submissions(self): + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id,))) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') + self.assertEqual(len(response.context['data_rows']), 2) + + # check display of list values within form submissions + self.assertContains(response, 'Much excitement') + self.assertContains(response, 'White Chocolate') + self.assertContains(response, 'Dark Chocolate') + + def test_list_submissions_pagination(self): + self.make_list_submissions() + + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id,)), {'p': 2}) + + # Check response + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'wagtailforms/index_submissions.html') + + # test that paginate by 50 is working, should be 3 max pages (~120 values) + self.assertContains(response, 'Page 2 of 3') + self.assertContains(response, 'Wet my pants excited!', count=50) + self.assertEqual(response.context['page_obj'].number, 2) + + def test_list_submissions_csv_export(self): + response = self.client.get( + reverse('wagtailforms:list_submissions', args=(self.form_page.id,)), + {'action': 'CSV'} + ) + + # Check response + self.assertEqual(response.status_code, 200) + data_lines = response.content.decode().split("\n") + self.assertIn('filename=%s-export' % self.form_page.slug, response.get('Content-Disposition')) + self.assertEqual(data_lines[0], 'Username,Submission date,Your email,Chocolate,Ingredients,Your Excitement\r') + # first result should be the most recent as order_csv has been reversed + self.assertEqual(data_lines[1], 'user-chocolate-maniac,2017-10-01 12:00:00+00:00,new@example.com,White Chocolate,White colouring,Much excitement\r') + self.assertEqual(data_lines[2], 'user-chocolate-guy,2017-01-01 12:00:00+00:00,old@example.com,Dark Chocolate,Charcoal,What is chocolate?\r') + + def test_list_submissions_ordering(self): + form_submission = CustomFormPageSubmission.objects.create( + page=self.form_page, + user=self.create_test_user_without_admin('user-aaa-aaa'), + form_data=json.dumps({ + 'your-email': 'new@example.com', + 'chocolate': 'Old chocolate idea', + 'ingredients': 'Sugar', + 'your-excitement': self.choices[2], + }), + ) + form_submission.submit_time = '2016-01-01T12:00:00.000Z' + form_submission.save() + + # check ordering matches default which is overriden to be 'submit_time' (oldest first) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id,))) + first_row_values = response.context['data_rows'][0]['fields'] + self.assertTrue('Old chocolate idea' in first_row_values) + + class TestIssue585(TestCase): fixtures = ['test.json'] diff --git a/wagtail/contrib/forms/urls.py b/wagtail/contrib/forms/urls.py index a07200d9f6..222126777a 100644 --- a/wagtail/contrib/forms/urls.py +++ b/wagtail/contrib/forms/urls.py @@ -1,10 +1,10 @@ from django.conf.urls import url -from wagtail.contrib.forms import views +from wagtail.contrib.forms.views import DeleteSubmissionsView, FormPagesListView, get_submissions_list_view app_name = 'wagtailforms' urlpatterns = [ - url(r'^$', views.index, name='index'), - url(r'^submissions/(\d+)/$', views.list_submissions, name='list_submissions'), - url(r'^submissions/(\d+)/delete/$', views.delete_submissions, name='delete_submissions') + url(r'^$', FormPagesListView.as_view(), name='index'), + url(r'^submissions/(?P\d+)/$', get_submissions_list_view, name='list_submissions'), + url(r'^submissions/(?P\d+)/delete/$', DeleteSubmissionsView.as_view(), name='delete_submissions') ] diff --git a/wagtail/contrib/forms/utils.py b/wagtail/contrib/forms/utils.py new file mode 100644 index 0000000000..e9dd75665d --- /dev/null +++ b/wagtail/contrib/forms/utils.py @@ -0,0 +1,36 @@ +from django.contrib.contenttypes.models import ContentType + +from wagtail.core import hooks +from wagtail.core.models import UserPagePermissionsProxy, get_page_models + + +_FORM_CONTENT_TYPES = None + + +def get_form_types(): + global _FORM_CONTENT_TYPES + if _FORM_CONTENT_TYPES is None: + from wagtail.contrib.forms.models import AbstractForm + form_models = [ + model for model in get_page_models() + if issubclass(model, AbstractForm) + ] + + _FORM_CONTENT_TYPES = list( + ContentType.objects.get_for_models(*form_models).values() + ) + return _FORM_CONTENT_TYPES + + +def get_forms_for_user(user): + """ + Return a queryset of form pages that this user is allowed to access the submissions for + """ + editable_forms = UserPagePermissionsProxy(user).editable_pages() + editable_forms = editable_forms.filter(content_type__in=get_form_types()) + + # Apply hooks + for fn in hooks.get_hooks('filter_form_submissions_for_user'): + editable_forms = fn(user, editable_forms) + + return editable_forms diff --git a/wagtail/contrib/forms/views.py b/wagtail/contrib/forms/views.py index e08b2241e9..5f9cbcab34 100644 --- a/wagtail/contrib/forms/views.py +++ b/wagtail/contrib/forms/views.py @@ -2,152 +2,311 @@ import csv import datetime from django.core.exceptions import PermissionDenied +from django.core.paginator import InvalidPage from django.http import HttpResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, redirect from django.utils.encoding import smart_str from django.utils.translation import ungettext +from django.views.generic import ListView, TemplateView -from wagtail.utils.pagination import paginate from wagtail.admin import messages -from wagtail.core.models import Page from wagtail.contrib.forms.forms import SelectDateForm -from wagtail.contrib.forms.models import get_forms_for_user +from wagtail.contrib.forms.utils import get_forms_for_user +from wagtail.core.models import Page +from wagtail.utils.pagination import DEFAULT_PAGE_KEY -def index(request): - form_pages = get_forms_for_user(request.user) - - paginator, form_pages = paginate(request, form_pages) - - return render(request, 'wagtailforms/index.html', { - 'form_pages': form_pages, - }) +def get_submissions_list_view(request, *args, **kwargs): + """ Call the form page's list submissions view class """ + page_id = kwargs.get('page_id') + form_page = get_object_or_404(Page, id=page_id).specific + return form_page.serve_submissions_list_view(request, *args, **kwargs) -def delete_submissions(request, page_id): - if not get_forms_for_user(request.user).filter(id=page_id).exists(): - raise PermissionDenied +class SafePaginateListView(ListView): + """ Listing view with safe pagination, allowing incorrect or out of range values """ - page = get_object_or_404(Page, id=page_id).specific + paginate_by = 20 + page_kwarg = DEFAULT_PAGE_KEY - # Get submissions - submission_ids = request.GET.getlist('selected-submissions') - submissions = page.get_submission_class()._default_manager.filter(id__in=submission_ids) + def paginate_queryset(self, queryset, page_size): + """Paginate the queryset if needed with nice defaults on invalid param.""" + paginator = self.get_paginator( + queryset, + page_size, + orphans=self.get_paginate_orphans(), + allow_empty_first_page=self.get_allow_empty() + ) + page_kwarg = self.page_kwarg + page_request = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 0 + try: + page_number = int(page_request) + except ValueError: + if page_request == 'last': + page_number = paginator.num_pages + else: + page_number = 0 + try: + if page_number > paginator.num_pages: + page_number = paginator.num_pages # page out of range, show last page + page = paginator.page(page_number) + return (paginator, page, page.object_list, page.has_other_pages()) + except InvalidPage: + page = paginator.page(1) + return (paginator, page, page.object_list, page.has_other_pages()) + return super().paginage_queryset(queryset, page_size) - if request.method == 'POST': + +class FormPagesListView(SafePaginateListView): + """ Lists the available form pages for the current user """ + template_name = 'wagtailforms/index.html' + context_object_name = 'form_pages' + + def get_queryset(self): + """ Return the queryset of form pages for this view """ + queryset = get_forms_for_user(self.request.user) + ordering = self.get_ordering() + if ordering: + if isinstance(ordering, str): + ordering = (ordering,) + queryset = queryset.order_by(*ordering) + return queryset + + +class DeleteSubmissionsView(TemplateView): + """ Delete the selected submissions """ + template_name = 'wagtailforms/confirm_delete.html' + page = None + submissions = None + success_url = 'wagtailforms:list_submissions' + + def get_queryset(self): + """ Returns a queryset for the selected submissions """ + submission_ids = self.request.GET.getlist('selected-submissions') + submission_class = self.page.get_submission_class() + return submission_class._default_manager.filter(id__in=submission_ids) + + def handle_delete(self, submissions): + """ Deletes the given queryset """ count = submissions.count() submissions.delete() - messages.success( - request, + self.request, ungettext( - "One submission has been deleted.", - "%(count)d submissions have been deleted.", + 'One submission has been deleted.', + '%(count)d submissions have been deleted.', count - ) % { - 'count': count, - } + ) % {'count': count} ) - return redirect('wagtailforms:list_submissions', page_id) + def get_success_url(self): + """ Returns the success URL to redirect to after a successful deletion """ + return self.success_url - return render(request, 'wagtailforms/confirm_delete.html', { - 'page': page, - 'submissions': submissions, - }) + def dispatch(self, request, *args, **kwargs): + """ Check permissions, set the page and submissions, handle delete """ + page_id = kwargs.get('page_id') + if not get_forms_for_user(self.request.user).filter(id=page_id).exists(): + raise PermissionDenied -def list_submissions(request, page_id): - if not get_forms_for_user(request.user).filter(id=page_id).exists(): - raise PermissionDenied + self.page = get_object_or_404(Page, id=page_id).specific - form_page = get_object_or_404(Page, id=page_id).specific - form_submission_class = form_page.get_submission_class() + self.submissions = self.get_queryset() - data_fields = form_page.get_data_fields() + if self.request.method == 'POST': + self.handle_delete(self.submissions) + return redirect(self.get_success_url(), page_id) - ordering = form_page.get_field_ordering(request.GET.getlist('order_by')) + return super().dispatch(request, *args, **kwargs) - # convert ordering tuples to a list of strings like ['-submit_time'] - ordering_strings = [ - '%s%s' % ('-' if order_str[1] == 'descending' else '', order_str[0]) - for order_str in ordering] + def get_context_data(self, **kwargs): + """ Get the context for this view """ + context = super().get_context_data(**kwargs) - if request.GET.get('action') == 'CSV': - # Revert to CSV being sorted submit_time ascending for backwards compatibility - submissions = form_submission_class.objects.filter(page=form_page).order_by('submit_time') - else: - submissions = form_submission_class.objects.filter(page=form_page).order_by(*ordering_strings) - - data_fields_with_ordering = [] - for name, label in data_fields: - order = None - for order_value in [o[1] for o in ordering if o[0] == name]: - order = order_value - data_fields_with_ordering.append({ - "name": name, - "label": label, - "order": order, + context.update({ + 'page': self.page, + 'submissions': self.submissions, }) - data_headings = [label for name, label in data_fields] + return context - select_date_form = SelectDateForm(request.GET) - if select_date_form.is_valid(): - date_from = select_date_form.cleaned_data.get('date_from') - date_to = select_date_form.cleaned_data.get('date_to') - # careful: date_to should be increased by 1 day since the submit_time - # is a time so it will always be greater - if date_to: - date_to += datetime.timedelta(days=1) - if date_from and date_to: - submissions = submissions.filter(submit_time__range=[date_from, date_to]) - elif date_from and not date_to: - submissions = submissions.filter(submit_time__gte=date_from) - elif not date_from and date_to: - submissions = submissions.filter(submit_time__lte=date_to) - if request.GET.get('action') == 'CSV': - # return a CSV instead +class SubmissionsListView(SafePaginateListView): + """ Lists submissions for the provided form page """ + template_name = 'wagtailforms/index_submissions.html' + context_object_name = 'submissions' + form_page = None + ordering = ('-submit_time',) + ordering_csv = ('submit_time',) # keep legacy CSV ordering + orderable_fields = ('id', 'submit_time',) # used to validate ordering in URL + select_date_form = None + + def dispatch(self, request, *args, **kwargs): + """ Check permissions and set the form page """ + + self.form_page = kwargs.get('form_page') + + if not get_forms_for_user(request.user).filter(pk=self.form_page.id).exists(): + raise PermissionDenied + + self.is_csv_export = (self.request.GET.get('action') == 'CSV') + if self.is_csv_export: + self.paginate_by = None + + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self): + """ Return queryset of form submissions with filter and order_by applied """ + submission_class = self.form_page.get_submission_class() + queryset = submission_class._default_manager.filter(page=self.form_page) + + filtering = self.get_filtering() + if filtering and isinstance(filtering, dict): + queryset = queryset.filter(**filtering) + + ordering = self.get_ordering() + if ordering: + if isinstance(ordering, str): + ordering = (ordering,) + queryset = queryset.order_by(*ordering) + + return queryset + + def get_paginate_by(self, queryset): + """ Get the number of items to paginate by, or ``None`` for no pagination """ + if self.is_csv_export: + return None + return self.paginate_by + + def get_validated_ordering(self): + """ Return a dict of field names with ordering labels if ordering is valid """ + orderable_fields = self.orderable_fields or () + ordering = dict() + if self.is_csv_export: + # Revert to CSV order_by submit_time ascending for backwards compatibility + default_ordering = self.ordering_csv or () + else: + default_ordering = self.ordering or () + if isinstance(default_ordering, str): + default_ordering = (default_ordering,) + ordering_strs = self.request.GET.getlist('order_by') or list(default_ordering) + for order in ordering_strs: + try: + _, prefix, field_name = order.rpartition('-') + if field_name in orderable_fields: + ordering[field_name] = ( + prefix, 'descending' if prefix == '-' else 'ascending' + ) + except (IndexError, ValueError): + continue # invalid ordering specified, skip it + return ordering + + def get_ordering(self): + """ Return the field or fields to use for ordering the queryset """ + ordering = self.get_validated_ordering() + return [values[0] + name for name, values in ordering.items()] + + def get_filtering(self): + """ Return filering as a dict for submissions queryset """ + self.select_date_form = SelectDateForm(self.request.GET) + result = dict() + if self.select_date_form.is_valid(): + date_from = self.select_date_form.cleaned_data.get('date_from') + date_to = self.select_date_form.cleaned_data.get('date_to') + if date_to: + # careful: date_to must be increased by 1 day + # as submit_time is a time so will always be greater + date_to += datetime.timedelta(days=1) + if date_from: + result['submit_time__range'] = [date_from, date_to] + else: + result['submit_time__lte'] = date_to + elif date_from: + result['submit_time__gte'] = date_from + return result + + def get_csv_filename(self): + """ Returns the filename for the generated CSV file """ + return 'export-{}.csv'.format( + datetime.datetime.today().strftime('%Y-%m-%d') + ) + + def get_csv_response(self, context): + """ Returns a CSV response """ + filename = self.get_csv_filename() response = HttpResponse(content_type='text/csv; charset=utf-8') - response['Content-Disposition'] = 'attachment;filename=export.csv' - - # Prevents UnicodeEncodeError for labels with non-ansi symbols - data_headings = [smart_str(label) for label in data_headings] + response['Content-Disposition'] = 'attachment;filename={}'.format(filename) writer = csv.writer(response) - writer.writerow(data_headings) - for s in submissions: - data_row = [] - form_data = s.get_data() - for name, label in data_fields: - val = form_data.get(name) - if isinstance(val, list): - val = ', '.join(val) - data_row.append(smart_str(val)) + writer.writerow(context['data_headings']) + for data_row in context['data_rows']: writer.writerow(data_row) return response - paginator, submissions = paginate(request, submissions) + def render_to_response(self, context, **response_kwargs): + if self.is_csv_export: + return self.get_csv_response(context) + return super().render_to_response(context, **response_kwargs) - data_rows = [] - for s in submissions: - form_data = s.get_data() - data_row = [] - for name, label in data_fields: - val = form_data.get(name) - if isinstance(val, list): - val = ', '.join(val) - data_row.append(val) - data_rows.append({ - "model_id": s.id, - "fields": data_row + def get_context_data(self, **kwargs): + """ Return context for view, handle CSV or normal output """ + context = super().get_context_data(**kwargs) + submissions = context[self.context_object_name] + data_fields = self.form_page.get_data_fields() + data_rows = [] + + if self.is_csv_export: + # Build data_rows as list of lists containing formatted data values + # Using smart_str prevents UnicodeEncodeError for values with non-ansi symbols + for submission in submissions: + form_data = submission.get_data() + data_row = [] + for name, label in data_fields: + val = form_data.get(name) + if isinstance(val, list): + val = ', '.join(val) + data_row.append(smart_str(val)) + data_rows.append(data_row) + data_headings = [smart_str(label) for name, label in data_fields] + else: + # Build data_rows as list of dicts containing model_id and fields + for submission in submissions: + form_data = submission.get_data() + data_row = [] + for name, label in data_fields: + val = form_data.get(name) + if isinstance(val, list): + val = ', '.join(val) + data_row.append(val) + data_rows.append({ + 'model_id': submission.id, + 'fields': data_row + }) + # Build data_headings as list of dicts containing model_id and fields + ordering_by_field = self.get_validated_ordering() + orderable_fields = self.orderable_fields + data_headings = [] + for name, label in data_fields: + order_label = None + if name in orderable_fields: + order = ordering_by_field.get(name) + if order: + order_label = order[1] # 'ascending' or 'descending' + else: + order_label = 'orderable' # not ordered yet but can be + data_headings.append({ + 'name': name, + 'label': label, + 'order': order_label, + }) + + context.update({ + 'form_page': self.form_page, + 'select_date_form': self.select_date_form, + 'data_headings': data_headings, + 'data_rows': data_rows, + 'submissions': submissions, }) - return render(request, 'wagtailforms/index_submissions.html', { - 'form_page': form_page, - 'select_date_form': select_date_form, - 'submissions': submissions, - 'data_fields_with_ordering': data_fields_with_ordering, - 'data_rows': data_rows - }) + return context diff --git a/wagtail/contrib/forms/wagtail_hooks.py b/wagtail/contrib/forms/wagtail_hooks.py index 7df3c10281..7b18b0524d 100644 --- a/wagtail/contrib/forms/wagtail_hooks.py +++ b/wagtail/contrib/forms/wagtail_hooks.py @@ -3,9 +3,9 @@ from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from wagtail.admin.menu import MenuItem -from wagtail.core import hooks from wagtail.contrib.forms import urls -from wagtail.contrib.forms.models import get_forms_for_user +from wagtail.contrib.forms.utils import get_forms_for_user +from wagtail.core import hooks @hooks.register('register_admin_urls') diff --git a/wagtail/tests/testapp/migrations/0025_auto_20171207_1657.py b/wagtail/tests/testapp/migrations/0025_auto_20171207_1657.py new file mode 100644 index 0000000000..580d44dd51 --- /dev/null +++ b/wagtail/tests/testapp/migrations/0025_auto_20171207_1657.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-12-07 07:57 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0040_page_draft_title'), + ('tests', '0024_tableblockstreampage'), + ] + + operations = [ + migrations.CreateModel( + name='FormFieldForCustomListViewPage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('label', models.CharField(help_text='The label of the form field', max_length=255, verbose_name='label')), + ('field_type', models.CharField(choices=[('singleline', 'Single line text'), ('multiline', 'Multi-line text'), ('email', 'Email'), ('number', 'Number'), ('url', 'URL'), ('checkbox', 'Checkbox'), ('checkboxes', 'Checkboxes'), ('dropdown', 'Drop down'), ('multiselect', 'Multiple select'), ('radio', 'Radio buttons'), ('date', 'Date'), ('datetime', 'Date/time'), ('hidden', 'Hidden field')], max_length=16, verbose_name='field type')), + ('required', models.BooleanField(default=True, verbose_name='required')), + ('choices', models.TextField(blank=True, help_text='Comma separated list of choices. Only applicable in checkboxes, radio and dropdown.', verbose_name='choices')), + ('default_value', models.CharField(blank=True, help_text='Default value. Comma separated values supported for checkboxes.', max_length=255, verbose_name='default value')), + ('help_text', models.CharField(blank=True, max_length=255, verbose_name='help text')), + ], + options={ + 'abstract': False, + 'ordering': ['sort_order'], + }, + ), + migrations.CreateModel( + name='FormPageWithCustomSubmissionListView', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ('to_address', models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, verbose_name='to address')), + ('from_address', models.CharField(blank=True, max_length=255, verbose_name='from address')), + ('subject', models.CharField(blank=True, max_length=255, verbose_name='subject')), + ('intro', wagtail.core.fields.RichTextField(blank=True)), + ('thank_you_text', wagtail.core.fields.RichTextField(blank=True)), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.AddField( + model_name='formfieldforcustomlistviewpage', + name='page', + field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_fields', to='tests.FormPageWithCustomSubmissionListView'), + ), + ] diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py index 59d29e8107..44418a43ac 100644 --- a/wagtail/tests/testapp/models.py +++ b/wagtail/tests/testapp/models.py @@ -37,6 +37,8 @@ from wagtail.snippets.edit_handlers import SnippetChooserPanel from wagtail.snippets.models import register_snippet from .forms import ValidatedPageForm +from .views import CustomSubmissionsListView + EVENT_AUDIENCE_CHOICES = ( ('public', "Public"), @@ -566,6 +568,48 @@ class CustomFormPageSubmission(AbstractFormSubmission): return form_data +# Custom form page with custom submission listing view and form submission + +class FormFieldForCustomListViewPage(AbstractFormField): + page = ParentalKey( + 'FormPageWithCustomSubmissionListView', + related_name='form_fields', + on_delete=models.CASCADE + ) + + +class FormPageWithCustomSubmissionListView(AbstractEmailForm): + """Form Page with customised submissions listing view""" + + intro = RichTextField(blank=True) + thank_you_text = RichTextField(blank=True) + + submissions_list_view_class = CustomSubmissionsListView + + def get_submission_class(self): + return CustomFormPageSubmission + + def get_data_fields(self): + data_fields = [ + ('username', 'Username'), + ] + data_fields += super().get_data_fields() + + return data_fields + + content_panels = [ + FieldPanel('title', classname="full title"), + FieldPanel('intro', classname="full"), + InlinePanel('form_fields', label="Form fields"), + FieldPanel('thank_you_text', classname="full"), + MultiFieldPanel([ + FieldPanel('to_address', classname="full"), + FieldPanel('from_address', classname="full"), + FieldPanel('subject', classname="full"), + ], "Email") + ] + + # Snippets class AdvertPlacement(models.Model): page = ParentalKey('wagtailcore.Page', related_name='advert_placements', on_delete=models.CASCADE) diff --git a/wagtail/tests/testapp/views.py b/wagtail/tests/testapp/views.py index a06b064c1e..cc13d682f3 100644 --- a/wagtail/tests/testapp/views.py +++ b/wagtail/tests/testapp/views.py @@ -4,6 +4,7 @@ from django.template.response import TemplateResponse from wagtail.admin import messages from wagtail.admin.utils import user_passes_test +from wagtail.contrib.forms.views import SubmissionsListView def user_is_called_bob(user): @@ -22,3 +23,14 @@ def message_test(request): return redirect('testapp_message_test') else: return TemplateResponse(request, 'wagtailadmin/base.html') + + +class CustomSubmissionsListView(SubmissionsListView): + paginate_by = 50 + ordering = ('submit_time',) + ordering_csv = ('-submit_time',) + + def get_csv_filename(self): + """ Returns the filename for CSV file with page title at start""" + filename = super().get_csv_filename() + return self.form_page.slug + '-' + filename