├── tests ├── __init__.py ├── forms │ ├── __init__.py │ └── test_form_builder.py ├── hooks │ ├── __init__.py │ ├── test_registering.py │ └── test_save_form_submission_data.py ├── views │ ├── __init__.py │ ├── test_advanced.py │ ├── test_admin_list.py │ ├── test_copy.py │ ├── test_delete.py │ └── test_submission_list.py ├── blocks │ ├── __init__.py │ ├── test_form_chooser_block.py │ ├── test_info_block.py │ └── test_form_block.py ├── fields │ ├── __init__.py │ ├── test_registering.py │ ├── test_streamfield.py │ ├── test_base_field.py │ └── test_hook_select_field.py ├── management │ ├── __init__.py │ └── test_prunesubmissions.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── serializers │ ├── __init__.py │ └── test_form_submission.py ├── templatetags │ ├── __init__.py │ ├── test_url_replace.py │ └── test_form.py ├── models │ ├── test_migrations.py │ ├── __init__.py │ ├── test_form_settings.py │ ├── test_form_submission.py │ └── test_form_submission_file.py ├── urls.py ├── test_urls.py ├── test_utils.py ├── settings.py ├── fixtures │ └── test.json └── test_case.py ├── docs ├── requirements.txt ├── changelog.rst ├── _static │ └── images │ │ ├── screen_1.png │ │ ├── screen_2.png │ │ ├── screen_3.png │ │ └── screen_4.png ├── screenshots.rst ├── README.md ├── housekeeping.rst ├── installation.rst ├── contributors.rst ├── Makefile ├── permissions.rst ├── settings.rst ├── usage.rst ├── advanced.rst ├── hooks.rst ├── index.rst ├── templates.rst ├── submission.rst └── conf.py ├── example ├── migrations │ ├── __init__.py │ ├── 0002_advancedformsetting.py │ ├── 0001_initial.py │ └── 0003_alter_basicpage_body.py ├── templates │ ├── streamforms │ │ ├── partials │ │ │ └── form_field.html │ │ └── form_block.html │ └── example │ │ └── basic_page.html ├── scss │ ├── karma.scss │ ├── _variables.scss │ └── module │ │ ├── _layout.scss │ │ ├── _message.scss │ │ └── _form.scss ├── wsgi.py ├── urls.py ├── models.py ├── wagtailstreamforms_hooks.py ├── wagtailstreamforms_fields.py └── settings.py ├── wagtailstreamforms ├── utils │ ├── __init__.py │ ├── version.py │ ├── general.py │ ├── requests.py │ ├── apps.py │ └── loading.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── prunesubmissions.py ├── migrations │ ├── __init__.py │ ├── 0002_form_site.py │ └── 0003_alter_form_fields.py ├── templatetags │ ├── __init__.py │ └── streamforms_tags.py ├── __init__.py ├── templates │ └── streamforms │ │ ├── non_existent_form.html │ │ ├── partials │ │ ├── form_field.html │ │ └── pagination_nav.html │ │ ├── form_block.html │ │ ├── advanced_settings.html │ │ ├── confirm_delete.html │ │ ├── confirm_copy.html │ │ ├── wagtailadmin │ │ └── shared │ │ │ └── datetimepicker_translations.html │ │ ├── list_submissions.html │ │ └── index_submissions.html ├── locale │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ └── tr │ │ └── LC_MESSAGES │ │ └── django.mo ├── views │ ├── __init__.py │ ├── advanced_settings.py │ ├── submission_delete.py │ ├── copy.py │ └── submission_list.py ├── serializers.py ├── models │ ├── abstract.py │ ├── submission.py │ ├── file.py │ ├── __init__.py │ └── form.py ├── urls.py ├── conf.py ├── wagtailstreamforms_hooks.py ├── hooks.py ├── streamfield.py ├── forms.py └── blocks.py ├── MANIFEST.in ├── readthedocs.yml ├── .github ├── ISSUE_TEMPLATE.md ├── workflows │ ├── python-release.yml │ ├── python-test.yml │ └── nightly-tests.yml ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── .dockerignore ├── .coveragerc ├── .gitignore ├── postcss.config.js ├── manage.py ├── .pre-commit-config.yaml ├── docker-entrypoint.sh ├── Makefile ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── package.json ├── tox.ini ├── pyproject.toml ├── README.rst └── CHANGELOG.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/forms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/blocks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fields/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[docs] -------------------------------------------------------------------------------- /example/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtailstreamforms/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtailstreamforms/utils/version.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtailstreamforms/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtailstreamforms/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtailstreamforms/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst -------------------------------------------------------------------------------- /wagtailstreamforms/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtailstreamforms/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "4.1.0" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include wagtailstreamforms *.py *.html *.js *.mo *.po 3 | -------------------------------------------------------------------------------- /docs/_static/images/screen_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/docs/_static/images/screen_1.png -------------------------------------------------------------------------------- /docs/_static/images/screen_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/docs/_static/images/screen_2.png -------------------------------------------------------------------------------- /docs/_static/images/screen_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/docs/_static/images/screen_3.png -------------------------------------------------------------------------------- /docs/_static/images/screen_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/docs/_static/images/screen_4.png -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | python: 3 | version: 3.12 4 | pip_install: true 5 | requirements_file: docs/requirements.txt 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected behaviour 2 | 3 | 4 | ### Actual behaviour 5 | 6 | 7 | ### Steps to reproduce the behaviour 8 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/non_existent_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

{% trans 'Sorry, this form has been deleted.' %}

-------------------------------------------------------------------------------- /wagtailstreamforms/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/wagtailstreamforms/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /wagtailstreamforms/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/wagtailstreamforms/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /wagtailstreamforms/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/wagtailstreamforms/HEAD/wagtailstreamforms/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .DS_Store 4 | .idea 5 | .python-version 6 | .tox 7 | dist 8 | htmlcov 9 | images 10 | node_modules 11 | wagtailstreamforms.egg-info -------------------------------------------------------------------------------- /wagtailstreamforms/utils/general.py: -------------------------------------------------------------------------------- 1 | from django.utils.text import slugify 2 | from unidecode import unidecode 3 | 4 | 5 | def get_slug_from_string(label): 6 | return str(slugify(str(unidecode(label)))) 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = wagtailstreamforms 4 | omit = 5 | */migrations/* 6 | parallel = true 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | raise NotImplementedError -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/partials/form_field.html: -------------------------------------------------------------------------------- 1 |
2 | {{ field.label_tag }} 3 | {{ field }} 4 | {% if field.help_text %}

{{ field.help_text }}

{% endif %} 5 | {{ field.errors }} 6 |
-------------------------------------------------------------------------------- /wagtailstreamforms/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .advanced_settings import AdvancedSettingsView # noqa 2 | from .copy import CopyFormView # noqa 3 | from .submission_delete import SubmissionDeleteView # noqa 4 | from .submission_list import SubmissionListView # noqa 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .DS_Store 4 | .idea 5 | .postgres 6 | .python-version 7 | .tox 8 | .coverage.* 9 | .venv 10 | **__pycache__** 11 | *.pyc 12 | /dist/ 13 | /docs/_build/ 14 | /htmlcov/ 15 | /node_modules/ 16 | /wagtailstreamforms.egg-info/ 17 | /build 18 | file*.mp4 19 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | map: { 5 | inline: false, 6 | annotation: true, 7 | sourcesContent: true 8 | }, 9 | plugins: { 10 | autoprefixer: { 11 | cascade: false 12 | }, 13 | } 14 | } -------------------------------------------------------------------------------- /example/templates/streamforms/partials/form_field.html: -------------------------------------------------------------------------------- 1 |
2 | {{ field.errors }} 3 | {{ field.label_tag }} 4 | {{ field }} 5 | {% if field.help_text %}

{{ field.help_text }}

{% endif %} 6 |
-------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/models/test_migrations.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | 3 | from tests.test_case import AppTestCase 4 | 5 | 6 | class Tests(AppTestCase): 7 | fixtures = ["test"] 8 | 9 | def test_migrations(self): 10 | call_command("makemigrations", dry_run=True, check=True) 11 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, re_path 3 | from wagtail import urls as wagtail_urls 4 | from wagtail.admin import urls as wagtailadmin_urls 5 | 6 | urlpatterns = [ 7 | re_path(r"^admin/", admin.site.urls), 8 | re_path(r"^cms/", include(wagtailadmin_urls)), 9 | re_path(r"", include(wagtail_urls)), 10 | ] 11 | -------------------------------------------------------------------------------- /wagtailstreamforms/serializers.py: -------------------------------------------------------------------------------- 1 | from django.core.serializers.json import DjangoJSONEncoder 2 | from django.db import models 3 | 4 | 5 | class FormSubmissionSerializer(DjangoJSONEncoder): 6 | """Form submission serializer""" 7 | 8 | def default(self, o): 9 | if isinstance(o, models.Model): 10 | return str(o) 11 | return super().default(o) 12 | -------------------------------------------------------------------------------- /example/scss/karma.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | /* karma-css core */ 4 | @import "node_modules/karma-css/scss/variables"; 5 | @import "node_modules/karma-css/scss/import"; 6 | 7 | /* add your custom modules here */ 8 | @import "module/form"; 9 | @import "module/layout"; 10 | @import "module/message"; 11 | 12 | /* karma-css utilities */ 13 | @import "node_modules/karma-css/scss/utilities"; -------------------------------------------------------------------------------- /wagtailstreamforms/models/abstract.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class AbstractFormSetting(models.Model): 5 | form = models.OneToOneField( 6 | "wagtailstreamforms.Form", 7 | on_delete=models.CASCADE, 8 | related_name="advanced_settings", 9 | ) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | def __str__(self): 15 | return self.form.title 16 | -------------------------------------------------------------------------------- /wagtailstreamforms/utils/requests.py: -------------------------------------------------------------------------------- 1 | from wagtailstreamforms.models import Form 2 | 3 | 4 | def get_form_instance_from_request(request): 5 | """Get the form class from the request.""" 6 | 7 | form_id = request.POST.get("form_id") 8 | if form_id and form_id.isdigit(): 9 | try: 10 | return Form.objects.get(pk=int(form_id)) 11 | except Form.DoesNotExist: 12 | pass 13 | return None 14 | -------------------------------------------------------------------------------- /docs/screenshots.rst: -------------------------------------------------------------------------------- 1 | Screenshots 2 | =========== 3 | 4 | .. figure:: _static/images/screen_1.png 5 | :width: 728 px 6 | 7 | Example Front End 8 | 9 | .. figure:: _static/images/screen_2.png 10 | :width: 728 px 11 | 12 | Form Listing 13 | 14 | .. figure:: _static/images/screen_3.png 15 | :width: 728 px 16 | 17 | Form Fields Selection 18 | 19 | .. figure:: _static/images/screen_4.png 20 | :width: 728 px 21 | 22 | Submission Listing -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from wagtailstreamforms.fields import HookSelectField 4 | from wagtailstreamforms.models import AbstractFormSetting 5 | 6 | 7 | class ValidFormSettingsModel(AbstractFormSetting): 8 | name = models.CharField(max_length=255) 9 | number = models.IntegerField() 10 | 11 | 12 | class InvalidFormSettingsModel(models.Model): 13 | pass 14 | 15 | 16 | class HookSelectModel(models.Model): 17 | hooks = HookSelectField(null=True, blank=True, help_text="Some hooks") 18 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/form_block.html: -------------------------------------------------------------------------------- 1 |

{{ value.form.title }}

2 | 3 | {{ form.media }} 4 | {% csrf_token %} 5 | {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} 6 | {% for field in form.visible_fields %} 7 | {% include 'streamforms/partials/form_field.html' %} 8 | {% endfor %} 9 | 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # File format: https://pre-commit.com/#plugins 2 | # Supported hooks: https://pre-commit.com/hooks.html 3 | # Running "make format" fixes most issues for you 4 | repos: 5 | - repo: https://github.com/charliermarsh/ruff-pre-commit 6 | # Ruff version. 7 | rev: "v0.11.0" 8 | hooks: 9 | - id: ruff 10 | - id: ruff-format 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v5.0.0 13 | hooks: 14 | - id: check-merge-conflict 15 | - id: debug-statements 16 | - id: trailing-whitespace 17 | -------------------------------------------------------------------------------- /tests/fields/test_registering.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from wagtailstreamforms import fields 4 | 5 | from ..test_case import AppTestCase 6 | 7 | 8 | class MyField(fields.BaseField): 9 | field_class = forms.CharField 10 | 11 | 12 | class TestFieldRegistering(AppTestCase): 13 | @classmethod 14 | def setUpClass(cls): 15 | fields.register("myfield", MyField) 16 | 17 | @classmethod 18 | def tearDownClass(cls): 19 | del fields._fields["myfield"] 20 | 21 | def test_field(self): 22 | self.assertIn("myfield", fields.get_fields()) 23 | -------------------------------------------------------------------------------- /example/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // colors 2 | $colors: ( 3 | "white": rgb(255, 255, 255), 4 | "primary": rgb(67, 177, 176), 5 | "mineshaft": rgb(51, 51, 51) 6 | 7 | ); 8 | 9 | // defined colors 10 | $body-background: rgb(253, 253, 253); 11 | 12 | 13 | // forms 14 | $input-use-full-width: true; 15 | 16 | 17 | // buttons 18 | $buttons: ( 19 | // name color font-color 20 | mineshaft: map-get($colors, "mineshaft") map-get($colors, "white"), 21 | ) !default; 22 | -------------------------------------------------------------------------------- /example/scss/module/_layout.scss: -------------------------------------------------------------------------------- 1 | // layout 2 | // ------------------------------------------------- 3 | 4 | header { 5 | @include box-shadow(0, 0, 10px, color("gray")); 6 | background: color("primary"); 7 | margin-bottom: 5rem; 8 | padding: 5rem 0; 9 | 10 | h1 { 11 | color: color("white"); 12 | } 13 | } 14 | 15 | footer { 16 | padding-top: 10rem; 17 | 18 | .links { 19 | list-style-type: none; 20 | 21 | li { 22 | display: inline; 23 | } 24 | 25 | li + li { 26 | margin-left: 2rem; 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Wagtail Streamforms docs 2 | 3 | These are Sphinx docs, automatically built when the master branch is committed to Github. To build them locally, install the development requirements: 4 | 5 | pip install -e .[docs] 6 | 7 | To build the documentation for browsing, from this directory run: 8 | 9 | make html 10 | 11 | then open ``_build/html/index.html`` in a browser. 12 | 13 | To rebuild automatically while editing the documentation, from this directory run: 14 | 15 | sphinx-autobuild . _build 16 | 17 | The online editor at http://rst.ninjs.org/ is a helpful tool for checking reStructuredText syntax. -------------------------------------------------------------------------------- /example/scss/module/_message.scss: -------------------------------------------------------------------------------- 1 | // layout 2 | // ------------------------------------------------- 3 | 4 | .messages { 5 | list-style-type: none; 6 | margin-bottom: 4rem; 7 | 8 | li { 9 | padding: 1.5rem 2rem; 10 | border-radius: $global-radius; 11 | } 12 | 13 | li.success { 14 | border: 1px solid color("primary"); 15 | background: lighten(color("primary"), 45%); 16 | color: color("primary"); 17 | } 18 | 19 | li.error { 20 | border: 1px solid color("red"); 21 | background: color-lighten("red", 35%); 22 | color: color("red"); 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /docs/housekeeping.rst: -------------------------------------------------------------------------------- 1 | Housekeeping 2 | ============ 3 | 4 | Removing old form submissions 5 | ----------------------------- 6 | 7 | There is a management command you can use to remove submissions that are older than the 8 | supplied number of days to keep. 9 | 10 | .. code-block:: bash 11 | 12 | python manage.py prunesubmissions 30 13 | 14 | Where ``30`` is the number of days to keep before today. Passing ``0`` will keep today's submissions only. 15 | 16 | Or to run the command from code: 17 | 18 | .. code-block:: python 19 | 20 | from django.core.management import call_command 21 | 22 | call_command('prunesubmissions', 30) 23 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Wagtail Streamform is available on PyPI - to install it, just run: 5 | 6 | .. code-block:: python 7 | 8 | pip install wagtailstreamforms 9 | 10 | Once thats done you need to add the following to your ``INSTALLED_APPS`` settings: 11 | 12 | .. code-block:: python 13 | 14 | INSTALLED_APPS = [ 15 | ... 16 | 'wagtail_modeladmin', 17 | 'wagtailstreamforms' 18 | ... 19 | ] 20 | 21 | Run migrations: 22 | 23 | .. code-block:: bash 24 | 25 | python manage.py migrate 26 | 27 | Go to your cms admin area and you will see the ``Streamforms`` section. 28 | -------------------------------------------------------------------------------- /docs/contributors.rst: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | People that have helped in any way shape or form to get to where we are, many thanks. 5 | 6 | In our team 7 | ----------- 8 | 9 | * `Dave Fuller `_ 10 | * `Stuart George `_ 11 | * `Tim Donhou `_ 12 | 13 | In the community 14 | ---------------- 15 | 16 | * `Aimee Hendrycks `_ 17 | * `Aram Dulyan `_ 18 | * `José Luis `_ 19 | * `Nathan Victor `_ 20 | * `Tom Dyson `_ 21 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | export PGPASSWORD=$RDS_PASSWORD 5 | 6 | while ! psql -h $RDS_HOSTNAME -d $RDS_DB_NAME -p $RDS_PORT -U $RDS_USERNAME -c "SELECT version();" > /dev/null 2>&1; do 7 | echo 'Waiting for connection with db...' 8 | sleep 1; 9 | done; 10 | echo 'Connected to db...'; 11 | 12 | if [ "x$DJANGO_MANAGEPY_MIGRATE" = 'xon' ]; then 13 | python manage.py migrate --noinput 14 | fi 15 | 16 | if [ "x$DJANGO_MANAGEPY_COLLECTSTATIC" = 'xon' ]; then 17 | python manage.py collectstatic --noinput 18 | fi 19 | 20 | if [ "x$DJANGO_MANAGEPY_UPDATEINDEX" = 'xon' ]; then 21 | python manage.py update_index 22 | fi 23 | 24 | exec "$@" -------------------------------------------------------------------------------- /example/scss/module/_form.scss: -------------------------------------------------------------------------------- 1 | // Form 2 | // ------------------------------------------------- 3 | 4 | form h2 { 5 | margin-bottom: 3rem; 6 | } 7 | 8 | form .field-row { 9 | 10 | ul { 11 | list-style-type: none; 12 | } 13 | 14 | &.has-error { 15 | 16 | input, 17 | select, 18 | textarea { 19 | border-color: color("red"); 20 | } 21 | 22 | .errorlist { 23 | margin-bottom: 0; 24 | float: right; 25 | list-style-type: none; 26 | color: color("red"); 27 | } 28 | 29 | } 30 | 31 | } 32 | 33 | form .form-actions { 34 | padding-top: 3rem; 35 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install test upload docs 2 | 3 | 4 | install: 5 | pip install -e .[docs,test] 6 | 7 | test: 8 | DJANGO_SETTINGS_MODULE=tests.settings ./manage.py test 9 | 10 | retest: 11 | py.test --nomigrations --reuse-db --lf --ignore=tests/functional tests/ 12 | 13 | docs: 14 | $(MAKE) -C docs html 15 | 16 | format: 17 | ruff check --fix --select I wagtailstreamforms/ tests/ 18 | ruff format wagtailstreamforms/ tests/ 19 | 20 | # 21 | # Utility 22 | makemessages: 23 | ./manage.py makemessages -all 24 | 25 | compilemessages: 26 | ./manage.py compilemessages 27 | 28 | release: 29 | rm -rf dist/* 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /wagtailstreamforms/migrations/0002_form_site.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-11-22 10:27 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [("wagtailstreamforms", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="form", 13 | name="site", 14 | field=models.ForeignKey( 15 | blank=True, 16 | null=True, 17 | on_delete=django.db.models.deletion.SET_NULL, 18 | to="wagtailcore.Site", 19 | ), 20 | ) 21 | ] 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = WagtailStreamforms 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from wagtailstreamforms.urls import urlpatterns 4 | 5 | from .test_case import AppTestCase 6 | 7 | 8 | class UrlsTests(AppTestCase): 9 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL=None) 10 | def test_no_advanced_url_when_no_setting(self): 11 | self.reload_module("wagtailstreamforms.urls") 12 | self.assertEqual(len(urlpatterns), 4) 13 | 14 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="tests.ValidFormSettingsModel") 15 | def test_advanced_url_when_setting_exists(self): 16 | self.reload_module("wagtailstreamforms.urls") 17 | self.assertEqual(len(urlpatterns), 4) 18 | -------------------------------------------------------------------------------- /example/templates/streamforms/form_block.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ value.form.title }}

3 | {{ form.media }} 4 | {% csrf_token %} 5 | {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} 6 |
7 | {% for field in form.visible_fields %} 8 |
9 | {% include 'streamforms/partials/form_field.html' %} 10 |
11 | {% endfor %} 12 |
13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import include, re_path 4 | from django.contrib import admin 5 | 6 | from wagtail.admin import urls as wagtailadmin_urls 7 | from wagtail import urls as wagtail_urls 8 | from wagtail.documents import urls as wagtaildocs_urls 9 | 10 | 11 | urlpatterns = [ 12 | re_path(r"^admin/", admin.site.urls), 13 | re_path(r"^cms/", include(wagtailadmin_urls)), 14 | re_path(r"^documents/", include(wagtaildocs_urls)), 15 | ] 16 | 17 | if settings.DEBUG: # pragma: no cover 18 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 19 | 20 | urlpatterns += [ 21 | re_path(r"", include(wagtail_urls)), 22 | ] 23 | -------------------------------------------------------------------------------- /.github/workflows/python-release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release to PyPi 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install uv 15 | uses: astral-sh/setup-uv@v5 16 | 17 | - name: Set up Python 3.13 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.13 21 | - name: Install build requirements 22 | run: python -m pip install wheel 23 | - name: Build package 24 | run: uv build --sdist --wheel 25 | - name: Publish package 26 | uses: pypa/gh-action-pypi-publish@release/v1 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.pypi_password }} 30 | -------------------------------------------------------------------------------- /example/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from wagtail import blocks 3 | from wagtail.admin.panels import FieldPanel 4 | from wagtail.fields import StreamField 5 | from wagtail.models import Page 6 | 7 | from wagtailstreamforms.blocks import WagtailFormBlock 8 | from wagtailstreamforms.models.abstract import AbstractFormSetting 9 | 10 | 11 | class AdvancedFormSetting(AbstractFormSetting): 12 | to_address = models.EmailField() 13 | 14 | 15 | class BasicPage(Page): 16 | body = StreamField( 17 | [("rich_text", blocks.RichTextBlock()), ("form", WagtailFormBlock())], 18 | use_json_field=True, 19 | ) 20 | 21 | # show in menu ticked by default 22 | show_in_menus_default = True 23 | 24 | content_panels = Page.content_panels + [ 25 | FieldPanel("body"), 26 | ] 27 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/partials/pagination_nav.html: -------------------------------------------------------------------------------- 1 | {% load i18n streamforms_tags %} 2 | 3 | -------------------------------------------------------------------------------- /wagtailstreamforms/utils/apps.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django.apps import apps 4 | from django.utils.module_loading import module_has_submodule 5 | 6 | 7 | def get_app_modules(): 8 | """ 9 | Generator function that yields a module object for each installed app 10 | yields tuples of (app_name, module) 11 | """ 12 | 13 | for app in apps.get_app_configs(): 14 | yield app.name, app.module 15 | 16 | 17 | def get_app_submodules(submodule_name): 18 | """ 19 | Searches each app module for the specified submodule 20 | yields tuples of (app_name, module) 21 | """ 22 | 23 | for name, module in get_app_modules(): 24 | if module_has_submodule(module, submodule_name): 25 | yield name, import_module("%s.%s" % (name, submodule_name)) 26 | -------------------------------------------------------------------------------- /docs/permissions.rst: -------------------------------------------------------------------------------- 1 | Permissions 2 | =========== 3 | 4 | Setting the level of access to administer your forms is the 5 | same as it is for any page. The permissions will appear in the groups section of 6 | the wagtail admin > settings area. 7 | 8 | Here you can assign the usual add, change and delete permissions. 9 | 10 | .. note:: 11 | Its worth noting here that if you do delete a form it will also delete all submissions 12 | for that form. 13 | 14 | Form submission permissions 15 | --------------------------- 16 | 17 | Because the form submission model is not listed in the admin area the following statement applies. 18 | 19 | .. important:: 20 | If you can either add, change or delete a form then you can view all of its submissions. 21 | However to be able to delete the submissions, it requires that you can delete the form. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # run the app in development mode 2 | services: 3 | app: 4 | build: . 5 | tty: true 6 | volumes: 7 | - .:/code 8 | environment: 9 | - DJANGO_MANAGEPY_COLLECTSTATIC=off 10 | - DJANGO_SETTINGS_MODULE=example.settings 11 | - SECRET_KEY=secret 12 | - ALLOWED_HOSTS=* 13 | - RDS_HOSTNAME=db 14 | - RDS_PORT=5432 15 | - RDS_DB_NAME=postgres 16 | - RDS_USERNAME=postgres 17 | - RDS_PASSWORD=password 18 | depends_on: 19 | - db 20 | ports: 21 | - 8000:8000 22 | db: 23 | image: postgres:16.0 24 | environment: 25 | - POSTGRES_USER=postgres 26 | - POSTGRES_PASSWORD=password 27 | - POSTGRES_DB=postgres 28 | - PGDATA=/var/lib/postgresql/data/pgdata 29 | volumes: 30 | - ./.postgres:/var/lib/postgresql/data/pgdata 31 | ports: 32 | - 5432:5432 33 | -------------------------------------------------------------------------------- /wagtailstreamforms/models/submission.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class FormSubmission(models.Model): 8 | """Data for a form submission.""" 9 | 10 | form_data = models.TextField(_("Form data")) 11 | form = models.ForeignKey("Form", verbose_name=_("Form"), on_delete=models.CASCADE) 12 | submit_time = models.DateTimeField(_("Submit time"), auto_now_add=True) 13 | 14 | def get_data(self): 15 | """Returns dict with form data.""" 16 | form_data = json.loads(self.form_data) 17 | 18 | form_data.update({"submit_time": self.submit_time}) 19 | 20 | return form_data 21 | 22 | def __str__(self): 23 | return self.form_data 24 | 25 | class Meta: 26 | ordering = ["-submit_time"] 27 | verbose_name = _("Form submission") 28 | -------------------------------------------------------------------------------- /tests/management/test_prunesubmissions.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.management import call_command 4 | 5 | from tests.test_case import AppTestCase 6 | from wagtailstreamforms.models import Form, FormSubmission 7 | 8 | 9 | class Tests(AppTestCase): 10 | fixtures = ["test"] 11 | 12 | def test_command(self): 13 | form = Form.objects.get(pk=1) 14 | to_keep = FormSubmission.objects.create(form=form, form_data={}) 15 | to_delete = FormSubmission.objects.create(form=form, form_data={}) 16 | to_delete.submit_time = to_delete.submit_time - timedelta(days=2) 17 | to_delete.save() 18 | 19 | call_command("prunesubmissions", 1) 20 | 21 | FormSubmission.objects.get(pk=to_keep.pk) 22 | 23 | with self.assertRaises(FormSubmission.DoesNotExist): 24 | FormSubmission.objects.get(pk=to_delete.pk) 25 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Please ensure pull requests only contain changes for the feature/bug in question. 11 | 2. Update the README.rst with details of changes where applicable. 12 | 3. Update the docs with any relevant documentation. 13 | 4. Your Pull Request will be reviewed by at least one other developer and merged when appropriate. 14 | 5. If you are new to pull requests or just wish to request a new feature/idea feel free to raise an issue or 15 | contact us directly [support@accentdesign.co.uk](mailto://stuart@accentdesign.co.uk). 16 | -------------------------------------------------------------------------------- /wagtailstreamforms/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from wagtailstreamforms import views 4 | from wagtailstreamforms.utils.loading import get_advanced_settings_model 5 | 6 | SettingsModel = get_advanced_settings_model() 7 | 8 | 9 | urlpatterns = [ 10 | path("/copy/", views.CopyFormView.as_view(), name="streamforms_copy"), 11 | path( 12 | "/submissions/", 13 | views.SubmissionListView.as_view(), 14 | name="streamforms_submissions", 15 | ), 16 | path( 17 | "/submissions/delete/", 18 | views.SubmissionDeleteView.as_view(), 19 | name="streamforms_delete_submissions", 20 | ), 21 | ] 22 | 23 | 24 | if SettingsModel: # pragma: no cover 25 | urlpatterns += [ 26 | path( 27 | "/advanced/", 28 | views.AdvancedSettingsView.as_view(), 29 | name="streamforms_advanced", 30 | ) 31 | ] 32 | -------------------------------------------------------------------------------- /example/migrations/0002_advancedformsetting.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.5 on 2018-06-15 12:52 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('wagtailstreamforms', '0001_initial'), 11 | ('example', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='AdvancedFormSetting', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('to_address', models.EmailField(max_length=254)), 20 | ('form', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='advanced_settings', to='wagtailstreamforms.Form')), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim-bookworm 2 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 3 | 4 | # Copy the application code to the container: 5 | RUN mkdir /code/ 6 | WORKDIR /code/ 7 | ADD . /code/ 8 | 9 | # Install all build deps: 10 | RUN set -ex \ 11 | && apt-get update \ 12 | && apt-get install -y --no-install-recommends \ 13 | gcc \ 14 | gettext \ 15 | libjpeg-dev \ 16 | libpq-dev \ 17 | make \ 18 | postgresql-client \ 19 | || (cat /var/log/apt/term.log || true) \ 20 | && apt-get clean \ 21 | && rm -rf /var/lib/apt/lists/* 22 | 23 | RUN uv sync --frozen 24 | 25 | # expose port 26 | EXPOSE 8000 27 | 28 | # Docker entrypoint: 29 | ENV DJANGO_MANAGEPY_MIGRATE=on \ 30 | DJANGO_MANAGEPY_COLLECTSTATIC=on \ 31 | DJANGO_MANAGEPY_UPDATEINDEX=on 32 | 33 | ENTRYPOINT ["/code/docker-entrypoint.sh"] 34 | 35 | # Start python runserver: 36 | CMD ["uv", "run", "python", "manage.py", "runserver", "0.0.0.0:8000"] 37 | -------------------------------------------------------------------------------- /tests/models/test_form_settings.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from wagtailstreamforms.models import AbstractFormSetting, Form 4 | 5 | from ..test_case import AppTestCase 6 | from . import ValidFormSettingsModel 7 | 8 | 9 | class ModelGenericTests(AppTestCase): 10 | fixtures = ["test"] 11 | 12 | def test_abstract(self): 13 | self.assertTrue(AbstractFormSetting._meta.abstract) 14 | 15 | def test_str(self): 16 | model = ValidFormSettingsModel(form=Form.objects.get(pk=1)) 17 | self.assertEqual(model.__str__(), model.form.title) 18 | 19 | 20 | class ModelFieldTests(AppTestCase): 21 | def test_form(self): 22 | field = self.get_field(AbstractFormSetting, "form") 23 | self.assertModelField(field, models.OneToOneField) 24 | self.assertEqual(field.remote_field.model, "wagtailstreamforms.Form") 25 | self.assertEqual(field.remote_field.on_delete, models.CASCADE) 26 | self.assertEqual(field.remote_field.related_name, "advanced_settings") 27 | -------------------------------------------------------------------------------- /wagtailstreamforms/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | SETTINGS_PREFIX = "WAGTAILSTREAMFORMS" 5 | SETTINGS_DEFAULTS = { 6 | "ADMIN_MENU_LABEL": _("Streamforms"), 7 | "ADMIN_MENU_ORDER": None, 8 | "ADVANCED_SETTINGS_MODEL": None, 9 | "ENABLE_FORM_PROCESSING": True, 10 | "ENABLE_BUILTIN_HOOKS": True, 11 | "ENABLED_FIELDS": ( 12 | "singleline", 13 | "multiline", 14 | "date", 15 | "datetime", 16 | "email", 17 | "url", 18 | "number", 19 | "dropdown", 20 | "radio", 21 | "checkboxes", 22 | "checkbox", 23 | "hidden", 24 | "singlefile", 25 | "multifile", 26 | ), 27 | "FORM_TEMPLATES": [ 28 | ("streamforms/form_block.html", "Default Form Template"), 29 | ], 30 | } 31 | 32 | 33 | def get_setting(name): 34 | setting_key = "{}_{}".format(SETTINGS_PREFIX, name) 35 | return getattr(settings, setting_key, SETTINGS_DEFAULTS[name]) 36 | -------------------------------------------------------------------------------- /wagtailstreamforms/management/commands/prunesubmissions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from wagtailstreamforms.models import FormSubmission 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Deletes form submissions older than the provided number of days" 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument("days_to_keep", type=int) 13 | 14 | def get_queryset(self, date): 15 | return FormSubmission.objects.filter(submit_time__lt=date) 16 | 17 | def handle(self, *args, **options): 18 | keep_from_date = datetime.today().date() - timedelta(days=options["days_to_keep"]) 19 | 20 | queryset = self.get_queryset(keep_from_date) 21 | 22 | count = queryset.count() 23 | queryset.delete() 24 | 25 | msg = "Successfully deleted %s form submissions prior to %s" % ( 26 | count, 27 | keep_from_date, 28 | ) 29 | self.stdout.write(self.style.SUCCESS(msg)) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2018 Accent Design Group LTD 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /wagtailstreamforms/utils/loading.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from wagtailstreamforms.conf import get_setting 5 | from wagtailstreamforms.models import AbstractFormSetting 6 | 7 | 8 | def get_advanced_settings_model(): 9 | """ 10 | Returns the advanced form settings model if one is defined 11 | """ 12 | 13 | model = get_setting("ADVANCED_SETTINGS_MODEL") 14 | 15 | if not model: 16 | return 17 | 18 | def raise_error(msg): 19 | setting = "WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL" 20 | raise ImproperlyConfigured("%s %s" % (setting, msg)) 21 | 22 | try: 23 | model_class = apps.get_model(model, require_ready=False) 24 | if issubclass(model_class, AbstractFormSetting): 25 | return model_class 26 | raise_error("must inherit from 'wagtailstreamforms.models.AbstractFormSetting'") 27 | except ValueError: 28 | raise_error("must be of the form 'app_label.model_name'") 29 | except LookupError: 30 | raise_error("refers to model '%s' that has not been installed" % model) 31 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/advanced_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n %} 3 | {% block titletag %}{% blocktrans with form_title=object %}Advanced form settings for {{ form_title }}{% endblocktrans %}{% endblock %} 4 | {% block bodyclass %}menu-explorer{% endblock %} 5 | 6 | {% block content %} 7 | {% trans "Advanced form settings" as title_str %} 8 | {% include "wagtailadmin/shared/header.html" with title=title_str subtitle=object icon="doc-empty-inverse" %} 9 | 10 |
11 |
12 | {% csrf_token %} 13 |
    14 | {% block visible_fields %} 15 | {% for field in form.visible_fields %} 16 | {% include "wagtailadmin/shared/field.html" %} 17 | {% endfor %} 18 | {% endblock %} 19 |
  • 20 | 21 |
  • 22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /tests/blocks/test_form_chooser_block.py: -------------------------------------------------------------------------------- 1 | from wagtailstreamforms.blocks import FormChooserBlock 2 | from wagtailstreamforms.models import Form 3 | 4 | from ..test_case import AppTestCase 5 | 6 | 7 | class TestFormChooserBlockTestCase(AppTestCase): 8 | fixtures = ["test.json"] 9 | 10 | def setUp(self): 11 | self.form = Form.objects.get(pk=1) 12 | 13 | def test_value_for_form(self): 14 | block = FormChooserBlock() 15 | 16 | self.assertEqual(block.value_for_form(self.form.pk), self.form.pk) 17 | self.assertEqual(block.value_for_form(self.form), self.form) 18 | 19 | def test_value_from_form(self): 20 | block = FormChooserBlock() 21 | 22 | self.assertTrue(isinstance(block.value_from_form(self.form.pk), self.form.__class__)) 23 | self.assertTrue(isinstance(block.value_from_form(self.form), self.form.__class__)) 24 | 25 | def test_to_python(self): 26 | block = FormChooserBlock() 27 | 28 | self.assertIsNone(block.to_python(None)) 29 | self.assertIsNone(block.to_python(100)) 30 | 31 | self.assertTrue(isinstance(block.to_python(self.form.pk), self.form.__class__)) 32 | -------------------------------------------------------------------------------- /wagtailstreamforms/models/file.py: -------------------------------------------------------------------------------- 1 | from django.db import models, transaction 2 | from django.db.models.signals import post_delete 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class FormSubmissionFile(models.Model): 7 | """Data for a form submission file.""" 8 | 9 | submission = models.ForeignKey( 10 | "FormSubmission", 11 | verbose_name=_("Submission"), 12 | on_delete=models.CASCADE, 13 | related_name="files", 14 | ) 15 | field = models.CharField(verbose_name=_("Field"), max_length=255) 16 | file = models.FileField(verbose_name=_("File"), upload_to="streamforms/") 17 | 18 | def __str__(self): 19 | return self.file.name 20 | 21 | class Meta: 22 | ordering = ["field", "file"] 23 | verbose_name = _("Form submission file") 24 | 25 | @property 26 | def url(self): 27 | return self.file.url 28 | 29 | 30 | def delete_file_from_storage(instance, **kwargs): 31 | """Cleanup deleted files from disk""" 32 | transaction.on_commit(lambda: instance.file.delete(False)) 33 | 34 | 35 | post_delete.connect(delete_file_from_storage, sender=FormSubmissionFile) 36 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %}. 2 | {% load i18n %} 3 | {% block titletag %}{% blocktrans with form_title=object.title|capfirst %}Submissions of {{ form_title }}{% endblocktrans %}{% endblock %} 4 | {% block bodyclass %}menu-explorer{% endblock %} 5 | 6 | {% block content %} 7 | {% trans "Delete form data" as del_str %} 8 | {% include "wagtailadmin/shared/header.html" with title=del_str subtitle=object.title icon="doc-empty-inverse" %} 9 | 10 |
11 |

12 | {% blocktrans count counter=submissions.count %} 13 | Are you sure you want to delete this form submission? 14 | {% plural %} 15 | Are you sure you want to delete these form submissions? 16 | {% endblocktrans %} 17 |

18 |
19 | {% csrf_token %} 20 | 21 |
22 |
23 | {% endblock %} -------------------------------------------------------------------------------- /tests/blocks/test_info_block.py: -------------------------------------------------------------------------------- 1 | from wagtailstreamforms.blocks import InfoBlock 2 | 3 | from ..test_case import AppTestCase 4 | 5 | 6 | class TestInfoBlockTestCase(AppTestCase): 7 | def test_form_render_with_value(self): 8 | block = InfoBlock() 9 | 10 | test_form_html = block.render_form("foo") 11 | expected_html = "\n".join(['
foo
']) 12 | self.assertInHTML(expected_html, test_form_html) 13 | 14 | def test_form_render_no_value_with_help_text(self): 15 | block = InfoBlock(help_text="some help") 16 | 17 | test_form_html = block.render_form("") 18 | expected_html = "\n".join( 19 | ['
some help
'] 20 | ) 21 | self.assertInHTML(expected_html, test_form_html) 22 | 23 | def test_form_render_value_and_help_text(self): 24 | block = InfoBlock(help_text="some help") 25 | 26 | test_form_html = block.render_form("foo") 27 | expected_html = "\n".join(['
foo
']) 28 | self.assertInHTML(expected_html, test_form_html) 29 | -------------------------------------------------------------------------------- /tests/serializers/test_form_submission.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import date, datetime 3 | 4 | from django.contrib.auth.models import User 5 | 6 | from tests.test_case import AppTestCase 7 | from wagtailstreamforms.serializers import FormSubmissionSerializer 8 | 9 | 10 | class TestSerializer(AppTestCase): 11 | def test_serialized(self): 12 | data_to_serialize = { 13 | "model": User(username="fred"), 14 | "model_multiple": [User(username="fred"), User(username="joe")], 15 | "date": date(2018, 1, 1), 16 | "datetime": datetime(2018, 1, 1), 17 | "list": [1, 2], 18 | "string": "foo", 19 | "integer": 1, 20 | } 21 | expected_data = { 22 | "model": "fred", 23 | "model_multiple": ["fred", "joe"], 24 | "date": "2018-01-01", 25 | "datetime": "2018-01-01T00:00:00", 26 | "list": [1, 2], 27 | "string": "foo", 28 | "integer": 1, 29 | } 30 | 31 | json_data = json.dumps(data_to_serialize, cls=FormSubmissionSerializer) 32 | 33 | self.assertEqual(json_data, json.dumps(expected_data)) 34 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/confirm_copy.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n %} 3 | {% block titletag %}{% blocktrans with form_title=object.title|capfirst %}Copy of {{ form_title }}{% endblocktrans %}{% endblock %} 4 | {% block bodyclass %}menu-explorer{% endblock %} 5 | 6 | {% block content %} 7 | {% trans "Copy" as copy_str %} 8 | {% include "wagtailadmin/shared/header.html" with title=copy_str subtitle=object.title icon="doc-empty-inverse" %} 9 | 10 |
11 |

{% trans 'Are you sure you want to copy this form?' %}

12 |
13 | {% csrf_token %} 14 |
    15 | {% block visible_fields %} 16 | {% for field in form.visible_fields %} 17 | {% include "wagtailadmin/shared/field.html" %} 18 | {% endfor %} 19 | {% endblock %} 20 |
  • 21 | 22 |
  • 23 |
24 |
25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | Any settings with their defaults are listed below for quick reference. 5 | 6 | .. code-block:: python 7 | 8 | # the label of the forms area in the admin sidebar 9 | WAGTAILSTREAMFORMS_ADMIN_MENU_LABEL = 'Streamforms' 10 | 11 | # the order of the forms area in the admin sidebar 12 | WAGTAILSTREAMFORMS_ADMIN_MENU_ORDER = None 13 | 14 | # the model defined to save advanced form settings 15 | # in the format of 'app_label.model_class'. 16 | # Model must inherit from 'wagtailstreamforms.models.AbstractFormSetting'. 17 | WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL = None 18 | 19 | # enable the built in hook to process form submissions 20 | WAGTAILSTREAMFORMS_ENABLE_FORM_PROCESSING = True 21 | 22 | # enable the built in hooks defined in wagtailstreamforms 23 | # currently (save_form_submission_data) 24 | WAGTAILSTREAMFORMS_ENABLE_BUILTIN_HOOKS = True 25 | 26 | # the default form template choices 27 | WAGTAILSTREAMFORMS_FORM_TEMPLATES = ( 28 | ('streamforms/form_block.html', 'Default Form Template'), 29 | ) 30 | 31 | # show the form reference field in the list view and export 32 | WAGTAILSTREAMFORMS_SHOW_FORM_REFERENCE = True 33 | -------------------------------------------------------------------------------- /wagtailstreamforms/wagtailstreamforms_hooks.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.template.defaultfilters import pluralize 4 | 5 | from wagtailstreamforms.hooks import register 6 | from wagtailstreamforms.models import FormSubmissionFile 7 | from wagtailstreamforms.serializers import FormSubmissionSerializer 8 | 9 | 10 | @register("process_form_submission") 11 | def save_form_submission_data(instance, form): 12 | """saves the form submission data""" 13 | 14 | # copy the cleaned_data so we dont mess with the original 15 | submission_data = form.cleaned_data.copy() 16 | 17 | # change the submission data to a count of the files 18 | for field in form.files.keys(): 19 | count = len(form.files.getlist(field)) 20 | submission_data[field] = "{} file{}".format(count, pluralize(count)) 21 | 22 | # save the submission data 23 | submission = instance.get_submission_class().objects.create( 24 | form_data=json.dumps(submission_data, cls=FormSubmissionSerializer), 25 | form=instance, 26 | ) 27 | 28 | # save the form files 29 | for field in form.files: 30 | for file in form.files.getlist(field): 31 | FormSubmissionFile.objects.create(submission=submission, field=field, file=file) 32 | -------------------------------------------------------------------------------- /example/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.4 on 2018-04-30 08:06 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import wagtail.blocks 6 | import wagtail.fields 7 | import wagtailstreamforms.blocks 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('wagtailcore', '0040_page_draft_title'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='BasicPage', 21 | fields=[ 22 | ('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')), 23 | ('body', wagtail.fields.StreamField((('rich_text', wagtail.blocks.RichTextBlock()), ('form', wagtail.blocks.StructBlock((('form', wagtailstreamforms.blocks.FormChooserBlock()), ('form_action', wagtail.blocks.CharBlock(help_text='The form post action. "" or "." for the current page or a url', required=False)), ('form_reference', wagtailstreamforms.blocks.InfoBlock(help_text='This form will be given a unique reference once saved', required=False)))))))), 24 | ], 25 | options={ 26 | 'abstract': False, 27 | }, 28 | bases=('wagtailcore.page',), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "version": "", 4 | "description": "", 5 | "homepage": "http://karmacss.com", 6 | "author": "Accent Design ", 7 | "keywords": [], 8 | "license": "MIT", 9 | "scripts": { 10 | "css": "npm-run-all css-compile* --sequential css-prefix* css-minify*", 11 | "css-compile": "node-sass --output-style expanded --source-map true --source-map-contents true --precision 6 example/scss/karma.scss example/static/css/karma.css", 12 | "css-prefix": "postcss --config postcss.config.js --replace \"example/static/css/*.css\" \"!example/static/css/*.min.css\"", 13 | "css-minify": "cleancss --level 1 --source-map --source-map-inline-sources --output example/static/css/karma.min.css example/static/css/karma.css", 14 | "watch-css": "nodemon -e scss -x \"npm run css\"" 15 | }, 16 | "dependencies": {}, 17 | "devDependencies": { 18 | "autoprefixer": "^7.2.5", 19 | "clean-css-cli": "^4.1.10", 20 | "karma-css": "^1.9.5", 21 | "node-sass": "^4.7.2", 22 | "nodemon": "^1.14.11", 23 | "npm-run-all": "^4.1.2", 24 | "postcss-cli": "^5.0.0" 25 | }, 26 | "browserslist": [ 27 | "last 1 major version", 28 | ">= 1%", 29 | "Chrome >= 45", 30 | "Firefox >= 38", 31 | "Edge >= 12", 32 | "Explorer >= 10", 33 | "iOS >= 9", 34 | "Safari >= 9", 35 | "Android >= 4.4", 36 | "Opera >= 30" 37 | ] 38 | } -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/wagtailadmin/shared/datetimepicker_translations.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 40 | -------------------------------------------------------------------------------- /wagtailstreamforms/templatetags/streamforms_tags.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from django.template import Library 4 | from django.utils.safestring import mark_safe 5 | 6 | from wagtailstreamforms.blocks import WagtailFormBlock 7 | from wagtailstreamforms.models import Form 8 | 9 | register = Library() 10 | 11 | 12 | @register.simple_tag(takes_context=True) 13 | def url_replace(context, **kwargs): 14 | """will append kwargs to the existing url replacing any passed in""" 15 | 16 | query = context["request"].GET.dict() 17 | query.update(kwargs) 18 | return urlencode(query) 19 | 20 | 21 | @register.simple_tag(takes_context=True) 22 | def streamforms_form(context, slug, reference, action=".", **kwargs): 23 | """ 24 | Renders a form on the page. 25 | 26 | {% load streamforms_tags %} 27 | {% streamforms_form "the-form-slug" "some-unique-reference" "." %} 28 | """ 29 | 30 | try: 31 | form = Form.objects.get(slug=slug) 32 | 33 | block = WagtailFormBlock() 34 | 35 | # the context is a RequestContext, we need to turn it into a dict or 36 | # the blocks in wagtail will start to fail with dict(context) 37 | return block.render( 38 | block.to_python({"form": form.pk, "form_action": action, "form_reference": reference}), 39 | context.flatten(), 40 | ) 41 | 42 | except Form.DoesNotExist: 43 | return mark_safe("") 44 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Basic Usage 2 | =========== 3 | 4 | Just add the ``wagtailstreamforms.blocks.WagtailFormBlock()`` in any of your streamfields: 5 | 6 | .. code-block:: python 7 | 8 | body = StreamField([ 9 | ... 10 | ('form', WagtailFormBlock()) 11 | ... 12 | ]) 13 | 14 | And you are ready to go. 15 | 16 | Using the template tag 17 | ---------------------- 18 | 19 | There is also a template tag you can use outside of a streamfield, within a page. 20 | All this is doing is rendering the form using the same block as in the streamfield. 21 | 22 | The tag takes three parameters: 23 | 24 | * **slug** (``string``) - The slug of the form instance. 25 | * **reference** (``string``) - This should be a unique string and needs to be persistent on refresh/reload. See note below. 26 | * **action** (``string``) optional - The form action url. 27 | 28 | .. note:: The reference is used when the form is being validated. 29 | 30 | Because you can have any number of the same form on a page there needs to be a way of uniquely identifying the form beyond its ``PK``. 31 | This is so that when the form has validation errors and it is passed back through the pages context, We know what form it is. 32 | 33 | This reference **MUST** be persistent on page refresh or you will never see the errors. 34 | 35 | Usage: 36 | 37 | :: 38 | 39 | {% load streamforms_tags %} 40 | {% streamforms_form slug="form-slug" reference="some-very-unique-reference" action="." %} 41 | 42 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced Settings 2 | ================= 3 | 4 | Some times there is a requirement to save additional data for each form. 5 | Such as details of where to email the form submission. When this is needed we have 6 | provided the means to define your own model. 7 | 8 | To enable this you need to declare a model that inherits from 9 | ``wagtailstreamforms.models.AbstractFormSetting``: 10 | 11 | .. code-block:: python 12 | 13 | from wagtailstreamforms.models.abstract import AbstractFormSetting 14 | 15 | class AdvancedFormSetting(AbstractFormSetting): 16 | to_address = models.EmailField() 17 | 18 | Once that's done you need to add a setting to point to that model: 19 | 20 | .. code-block:: python 21 | 22 | # the model defined to save advanced form settings 23 | # in the format of 'app_label.model_class'. 24 | # Model must inherit from 'wagtailstreamforms.models.AbstractFormSetting'. 25 | WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL = 'myapp.AdvancedFormSetting' 26 | 27 | A button will appear on the Streamforms listing view ``Advanced`` which will 28 | allow you to edit that model. 29 | 30 | Usage 31 | ----- 32 | 33 | The data saved can be used in :ref:`hooks` on the ``instance.advanced_settings`` property. 34 | 35 | .. code-block:: python 36 | 37 | @register('process_form_submission') 38 | def email_submission(instance, form): 39 | send_mail( 40 | .. 41 | recipient_list=[instance.advanced_settings.to_address] 42 | ) -------------------------------------------------------------------------------- /example/templates/example/basic_page.html: -------------------------------------------------------------------------------- 1 | {% load i18n static wagtailcore_tags wagtailuserbar %} 2 | 3 | 4 | 5 | 6 | 7 | {% firstof page.seo_title page.title %} 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |

Wagtail Streamforms

16 |
17 |
18 |
19 | {% if messages %} 20 |
    21 | {% for message in messages %} 22 | {{ message }} 23 | {% endfor %} 24 |
25 | {% endif %} 26 | {% for block in page.body %} 27 | {% include_block block %} 28 | {% endfor %} 29 |
30 | 34 |
35 |
36 | {% wagtailuserbar %} 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | format: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install uv 11 | uses: astral-sh/setup-uv@v5 12 | - name: Set up Python 3.11 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.11 16 | - name: Install dependencies 17 | run: uv pip install --system tox 18 | - name: Validate formatting 19 | run: uv run tox -e format 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | strategy: 24 | max-parallel: 4 25 | matrix: 26 | tox_env: 27 | - py311-dj42-wt60 28 | - py311-dj42-wt62 29 | - py311-dj42-wt64 30 | include: 31 | - python-version: "3.11" 32 | tox_env: py311-dj42-wt60 33 | - python-version: "3.11" 34 | tox_env: py311-dj42-wt62 35 | - python-version: "3.11" 36 | tox_env: py311-dj42-wt64 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Install uv 41 | uses: astral-sh/setup-uv@v5 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | - name: Install dependencies 47 | run: uv pip install --system tox tox-gh-actions 48 | - name: Test with tox 49 | run: uv run tox -e ${{ matrix.tox_env }} -- --index-url=https://pypi.python.org/simple/ 50 | -------------------------------------------------------------------------------- /example/wagtailstreamforms_hooks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import EmailMessage 3 | from django.template.defaultfilters import pluralize 4 | 5 | from wagtailstreamforms.hooks import register 6 | 7 | 8 | @register("process_form_submission") 9 | def email_submission(instance, form): 10 | """Send an email with the submission.""" 11 | 12 | if not hasattr(instance, "advanced_settings"): 13 | return 14 | 15 | addresses = [instance.advanced_settings.to_address] 16 | content = [ 17 | "Please see below submission\n", 18 | ] 19 | from_address = settings.DEFAULT_FROM_EMAIL 20 | subject = "New Form Submission : %s" % instance.title 21 | 22 | # build up the email content 23 | for field, value in form.cleaned_data.items(): 24 | if field in form.files: 25 | count = len(form.files.getlist(field)) 26 | value = "{} file{}".format(count, pluralize(count)) 27 | elif isinstance(value, list): 28 | value = ", ".join(value) 29 | content.append("{}: {}".format(field, value)) 30 | content = "\n".join(content) 31 | 32 | # create the email message 33 | email = EmailMessage(subject=subject, body=content, from_email=from_address, to=addresses) 34 | 35 | # attach any files submitted 36 | for field in form.files: 37 | for file in form.files.getlist(field): 38 | file.seek(0) 39 | email.attach(file.name, file.read(), file.content_type) 40 | 41 | # finally send the email 42 | email.send(fail_silently=True) 43 | -------------------------------------------------------------------------------- /wagtailstreamforms/models/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from .abstract import AbstractFormSetting # noqa 5 | from .file import FormSubmissionFile # noqa 6 | from .form import Form # noqa 7 | from .submission import FormSubmission # noqa 8 | 9 | 10 | def get_form_model_string(): 11 | """ 12 | Get the dotted ``app.Model`` name for the form model as a string. 13 | Useful for developers making Wagtail plugins that need to refer to the 14 | form model, such as in foreign keys, but the model itself is not required. 15 | """ 16 | return getattr(settings, "WAGTAILSTREAMFORMS_FORM_MODEL", "wagtailstreamforms.Form") 17 | 18 | 19 | def get_form_model(): 20 | """ 21 | Get the form model from the ``WAGTAILSTREAMFORMS_FORM_MODEL`` setting. 22 | Useful for developers making Wagtail plugins that need the form model. 23 | Defaults to the standard :class:`~wagtailstreamforms.Form` model 24 | if no custom model is defined. 25 | """ 26 | from django.apps import apps 27 | 28 | model_string = get_form_model_string() 29 | try: 30 | return apps.get_model(model_string) 31 | except ValueError: 32 | raise ImproperlyConfigured( 33 | "WAGTAILSTREAMFORMS_FORM_MODEL must be of the form 'app_label.model_name'" 34 | ) 35 | except LookupError: 36 | raise ImproperlyConfigured( 37 | "WAGTAILSTREAMFORMS_FORM_MODEL refers to model '%s' that has not been installed" 38 | % model_string 39 | ) 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{311,312}-dj{42}-wt{60,61,62,63,64,70} 4 | 5 | [gh-actions] 6 | python = 7 | "3.11": py311 8 | "3.12": py312 9 | 10 | [testenv] 11 | deps = 12 | mock 13 | django>=4.2,<5.0 14 | dj42: Django>=4.2,<5.0 15 | # Ensure modeladmin is installed alongside corresponding Wagtail versions 16 | wt60: wagtail>=6.0,<6.1 17 | wt60: wagtail-modeladmin>=1.0,<2.0 18 | wt62: wagtail>=6.2,<6.3 19 | wt62: wagtail-modeladmin>=1.0,<2.0 20 | wt64: wagtail>=6.4,<6.5 21 | wt64: wagtail-modeladmin>=1.0,<2.0 22 | wt70: wagtail>=7.0,<7.1 23 | wt70: wagtail-modeladmin>=1.0,<2.0 24 | 25 | commands = 26 | # Add a verification step to check if the package is installed 27 | python -c "import wagtail_modeladmin; print(f'wagtail_modeladmin installed at: {wagtail_modeladmin.__path__}')" 28 | python manage.py test 29 | 30 | basepython = 31 | py311: python3.11 32 | py312: python3.12 33 | 34 | setenv = 35 | DJANGO_SETTINGS_MODULE=tests.settings 36 | PYTHONPATH={toxinidir} 37 | TOX_ENV_NAME={envname} 38 | 39 | passenv = TOX_* 40 | 41 | [testenv:wagtaildev] 42 | basepython = python3.12 43 | install_command = pip install -e ".[test]" -U {opts} {packages} 44 | deps = 45 | git+https://github.com/wagtail/wagtail.git@master 46 | mock 47 | django>=4.2 48 | commands = 49 | python manage.py test 50 | ignore_errors = True 51 | 52 | [testenv:format] 53 | basepython = python3.12 54 | deps = 55 | ruff 56 | skip_install = true 57 | commands = 58 | ruff format --check wagtailstreamforms/ tests/ 59 | ruff check wagtailstreamforms/ tests/ 60 | -------------------------------------------------------------------------------- /tests/templatetags/test_url_replace.py: -------------------------------------------------------------------------------- 1 | import urllib.parse as urlparse 2 | from html import unescape 3 | 4 | from ..test_case import AppTestCase 5 | 6 | 7 | class TemplateTagTests(AppTestCase): 8 | def test_kwarg_added(self): 9 | fake_request = self.rf.get("/") 10 | rendered = self.render_template( 11 | "{% load streamforms_tags %}?{% url_replace page=1 %}", 12 | {"request": fake_request}, 13 | ) 14 | # parse the url as they can be reordered unpredictably 15 | parsed = urlparse.parse_qs(urlparse.urlparse(unescape(rendered)).query) 16 | self.assertDictEqual(parsed, {"page": ["1"]}) 17 | 18 | def test_kwarg_appended(self): 19 | fake_request = self.rf.get("/?foo=bar") 20 | rendered = self.render_template( 21 | "{% load streamforms_tags %}?{% url_replace page=1 %}", 22 | {"request": fake_request}, 23 | ) 24 | # parse the url as they can be reordered unpredictably 25 | parsed = urlparse.parse_qs(urlparse.urlparse(unescape(rendered)).query) 26 | self.assertDictEqual(parsed, {"foo": ["bar"], "page": ["1"]}) 27 | 28 | def test_kwarg_replaced(self): 29 | fake_request = self.rf.get("/?foo=bar&page=1") 30 | rendered = self.render_template( 31 | "{% load streamforms_tags %}?{% url_replace page=5 %}", 32 | {"request": fake_request}, 33 | ) 34 | # parse the url as they can be reordered unpredictably 35 | parsed = urlparse.parse_qs(urlparse.urlparse(unescape(rendered)).query) 36 | self.assertDictEqual(parsed, {"foo": ["bar"], "page": ["5"]}) 37 | -------------------------------------------------------------------------------- /.github/workflows/nightly-tests.yml: -------------------------------------------------------------------------------- 1 | # Test against the Wagtail main branch, nightly, as this is required by Wagtail Nest: 2 | # https://github.com/wagtail-nest 3 | 4 | name: Nightly Wagtail test 5 | 6 | on: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | 10 | workflow_dispatch: 11 | 12 | jobs: 13 | nightly-test: 14 | # Cannot check the existence of secrets, so limiting to repository name to prevent 15 | # all forks to run nightly. See: https://github.com/actions/runner/issues/520 16 | if: ${{ github.repository == 'labd/wagtailstreamforms' }} 17 | runs-on: ubuntu-latest 18 | 19 | services: 20 | postgres: 21 | image: postgres:10.8 22 | ports: 23 | - 5432:5432 24 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Install uv 30 | uses: astral-sh/setup-uv@v5 31 | 32 | - name: Set up Python 3.13 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: 3.13 36 | - name: Install dependencies 37 | run: | 38 | uv add "psycopg2>=2.6" 39 | uv pip install "git+https://github.com/wagtail/wagtail.git@main#egg=wagtail" 40 | uv pip install -e .[test] 41 | - name: Test 42 | id: test 43 | continue-on-error: true 44 | run: | 45 | ./manage.py test 46 | env: 47 | DATABASE_ENGINE: django.db.backends.postgresql 48 | DATABASE_HOST: localhost 49 | DATABASE_USER: postgres 50 | DATABASE_PASS: postgres 51 | DJANGO_SETTINGS_MODULE: tests.settings 52 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.test import override_settings 3 | 4 | from tests.models import ValidFormSettingsModel 5 | from wagtailstreamforms.utils.loading import get_advanced_settings_model 6 | 7 | from .test_case import AppTestCase 8 | 9 | 10 | class AdvancedSettingsTests(AppTestCase): 11 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL=None) 12 | def test_default_none(self): 13 | self.assertIsNone(get_advanced_settings_model()) 14 | 15 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="foo") 16 | def test_invalid_string(self): 17 | msg = "must be of the form 'app_label.model_name'" 18 | with self.assertRaisesMessage(ImproperlyConfigured, msg): 19 | get_advanced_settings_model() 20 | 21 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="foo.Bar") 22 | def test_invalid_import(self): 23 | msg = "refers to model 'foo.Bar' that has not been installed" 24 | with self.assertRaisesMessage(ImproperlyConfigured, msg): 25 | get_advanced_settings_model() 26 | 27 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="tests.InvalidFormSettingsModel") 28 | def test_invalid_model_inheritance(self): 29 | msg = "must inherit from 'wagtailstreamforms.models.AbstractFormSetting'" 30 | with self.assertRaisesMessage(ImproperlyConfigured, msg): 31 | get_advanced_settings_model() 32 | 33 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="tests.ValidFormSettingsModel") 34 | def test_valid_model_returns_class(self): 35 | self.assertIs(get_advanced_settings_model(), ValidFormSettingsModel) 36 | -------------------------------------------------------------------------------- /tests/models/test_form_submission.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from wagtailstreamforms.models import Form, FormSubmission 4 | 5 | from ..test_case import AppTestCase 6 | 7 | 8 | class ModelGenericTests(AppTestCase): 9 | def test_str(self): 10 | model = FormSubmission(form_data='{"foo": 1}') 11 | self.assertEqual(model.__str__(), model.form_data) 12 | 13 | def test_ordering(self): 14 | self.assertEqual(FormSubmission._meta.ordering, ["-submit_time"]) 15 | 16 | 17 | class ModelFieldTests(AppTestCase): 18 | def test_form_data(self): 19 | field = self.get_field(FormSubmission, "form_data") 20 | self.assertModelField(field, models.TextField) 21 | 22 | def test_form(self): 23 | field = self.get_field(FormSubmission, "form") 24 | self.assertModelPKField(field, Form, models.CASCADE) 25 | 26 | def test_submit_time(self): 27 | field = self.get_field(FormSubmission, "submit_time") 28 | self.assertModelField(field, models.DateTimeField, False, True) 29 | self.assertTrue(field.auto_now_add) 30 | 31 | 32 | class ModelPropertyTests(AppTestCase): 33 | fixtures = ["test.json"] 34 | 35 | def test_get_data(self): 36 | form = Form.objects.get(pk=1) 37 | model = FormSubmission.objects.create(form_data='{"foo": 1}', form=form) 38 | expected_data = {"foo": 1, "submit_time": model.submit_time} 39 | self.assertEqual(model.get_data(), expected_data) 40 | 41 | def test_get_data_blank(self): 42 | form = Form.objects.get(pk=1) 43 | model = FormSubmission.objects.create(form_data="{}", form=form) 44 | expected_data = {"submit_time": model.submit_time} 45 | self.assertEqual(model.get_data(), expected_data) 46 | -------------------------------------------------------------------------------- /wagtailstreamforms/views/advanced_settings.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.shortcuts import get_object_or_404 4 | from django.utils.translation import gettext as _ 5 | from django.views.generic import UpdateView 6 | 7 | from wagtailstreamforms.models import Form 8 | from wagtailstreamforms.utils.loading import get_advanced_settings_model 9 | from wagtailstreamforms.wagtail_hooks import FormURLHelper 10 | 11 | SettingsModel = get_advanced_settings_model() 12 | 13 | 14 | class AdvancedSettingsForm(forms.ModelForm): 15 | class Meta: 16 | exclude = ("form",) 17 | model = SettingsModel 18 | 19 | 20 | class AdvancedSettingsView(UpdateView): 21 | form_class = AdvancedSettingsForm 22 | model = SettingsModel 23 | template_name = "streamforms/advanced_settings.html" 24 | success_message = _("Form '%s' advanced settings updated.") 25 | 26 | @property 27 | def url_helper(self): 28 | return FormURLHelper(model=Form) 29 | 30 | def get_object(self, queryset=None): 31 | form_pk = self.kwargs.get("pk") 32 | form = get_object_or_404(Form, pk=form_pk) 33 | 34 | try: 35 | obj = self.model.objects.get(form=form) 36 | except self.model.DoesNotExist: 37 | obj = self.model(form=form) 38 | 39 | return obj 40 | 41 | def form_valid(self, form): 42 | response = super().form_valid(form) 43 | self.create_success_message() 44 | return response 45 | 46 | def create_success_message(self): 47 | success_message = self.success_message % self.object.form 48 | messages.success(self.request, success_message) 49 | 50 | def get_success_url(self): 51 | return self.url_helper.index_url 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.djlint] 2 | format_css = false 3 | ignore = "H031" 4 | 5 | [tool.ruff] 6 | line-length = 99 7 | extend-exclude = ["**/migrations/"] 8 | lint.extend-select = ["I"] 9 | lint.extend-ignore = ["F405", "E731"] 10 | 11 | [tool.pytest.ini_options] 12 | DJANGO_SETTINGS_MODULE = "tests.settings" 13 | testpaths = ["tests/"] 14 | django_find_project = false 15 | addopts = ["--nomigrations"] 16 | filterwarnings = [ 17 | "ignore::DeprecationWarning", 18 | "ignore::PendingDeprecationWarning", 19 | ] 20 | 21 | [tool.uv] 22 | dev-dependencies = ["mock", "pytest-django", "pytest", "pre-commit", "ruff"] 23 | 24 | [tool.setuptools.packages.find] 25 | include = ["wagtailstreamforms*"] 26 | exclude = ["tests*", "docs*"] 27 | 28 | 29 | [project] 30 | name = "wagtailstreamforms" 31 | version = "5.2.6" 32 | description = "" 33 | authors = [{ name = "Lab Digital BV", email = "info@labdigital.nl" }] 34 | dependencies = [ 35 | "wagtail>=6.0,<8.0", 36 | "Unidecode>=0.04.14,<2.0", 37 | "wagtail-generic-chooser>=0.7.0", 38 | "wagtail-modeladmin>=2.2.0", 39 | "django-recaptcha>=4.0.0", 40 | "psycopg>=3.2.9", 41 | "tox>=4.26.0", 42 | ] 43 | requires-python = ">=3.9" 44 | readme = "README.rst" 45 | license = { text = "Proprietary" } 46 | classifiers = [ 47 | "License :: Other/Proprietary License", 48 | "Programming Language :: Python", 49 | "Programming Language :: Python :: 3", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: 3.13", 55 | ] 56 | 57 | [build-system] 58 | requires = ["setuptools", "wheel"] 59 | build-backend = "setuptools.build_meta" 60 | -------------------------------------------------------------------------------- /tests/views/test_advanced.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import override_settings 3 | from django.urls import reverse 4 | 5 | from wagtailstreamforms.models import Form 6 | from wagtailstreamforms.wagtail_hooks import FormURLHelper 7 | 8 | from ..test_case import AppTestCase 9 | 10 | 11 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="tests.ValidFormSettingsModel") 12 | class AdvancedSettingsViewTestCase(AppTestCase): 13 | fixtures = ["test.json"] 14 | 15 | def setUp(self): 16 | User.objects.create_superuser("user", "user@test.com", "password") 17 | self.form = Form.objects.get(pk=1) 18 | self.advanced_url = reverse( 19 | "wagtailstreamforms:streamforms_advanced", kwargs={"pk": self.form.pk} 20 | ) 21 | self.client.login(username="user", password="password") 22 | 23 | def test_get_responds(self): 24 | response = self.client.get(self.advanced_url) 25 | self.assertEqual(response.status_code, 200) 26 | 27 | def test_invalid_form_responds(self): 28 | response = self.client.post(self.advanced_url, {"name": "", "number": ""}, follow=True) 29 | self.assertIn("This field is required.", response.content.decode()) 30 | 31 | def test_valid_post(self): 32 | response = self.client.post( 33 | self.advanced_url, data={"name": "foo", "number": 1}, follow=True 34 | ) 35 | self.assertEqual(response.status_code, 200) 36 | self.assertEqual(self.form.advanced_settings.name, "foo") 37 | 38 | def test_post_redirects(self): 39 | response = self.client.post(self.advanced_url, data={"name": "foo", "number": 1}) 40 | url_helper = FormURLHelper(model=Form) 41 | self.assertRedirects(response, url_helper.index_url) 42 | -------------------------------------------------------------------------------- /tests/fields/test_streamfield.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from wagtailstreamforms import fields 5 | from wagtailstreamforms.streamfield import FormFieldsStreamField 6 | 7 | from ..test_case import AppTestCase 8 | 9 | 10 | class GoodField(fields.BaseField): 11 | field_class = forms.CharField 12 | 13 | 14 | class TestCorrectTypeRegistering(AppTestCase): 15 | @classmethod 16 | def setUpClass(cls) -> None: 17 | fields.register("good", GoodField) 18 | 19 | @classmethod 20 | def tearDownClass(cls) -> None: 21 | del fields._fields["good"] 22 | 23 | def test_child_blocks(self) -> None: 24 | field = FormFieldsStreamField([]) 25 | self.assertIn("good", field.stream_block.child_blocks) 26 | 27 | def test_dependencies(self) -> None: 28 | field = FormFieldsStreamField([]) 29 | self.assertListEqual( 30 | [b.__class__ for b in field.stream_block.dependencies], 31 | [b.__class__ for b in field.stream_block.child_blocks.values()], 32 | ) 33 | 34 | 35 | class BadField: 36 | field_class = forms.CharField 37 | 38 | 39 | class TestIncorrectTypeRegistering(AppTestCase): 40 | @classmethod 41 | def setUpClass(cls) -> None: 42 | fields.register("bad", BadField) 43 | 44 | @classmethod 45 | def tearDownClass(cls) -> None: 46 | del fields._fields["bad"] 47 | 48 | def test_is_invalid_class(self) -> None: 49 | expected_error = "'%s' must be a subclass of '%s'" % ( 50 | BadField, 51 | fields.BaseField, 52 | ) 53 | 54 | with self.assertRaises(ImproperlyConfigured) as e: 55 | FormFieldsStreamField([]) 56 | 57 | self.assertEqual(e.exception.args[0], expected_error) 58 | -------------------------------------------------------------------------------- /wagtailstreamforms/hooks.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from wagtailstreamforms.conf import get_setting 4 | from wagtailstreamforms.utils.apps import get_app_submodules 5 | 6 | _hooks = {} 7 | 8 | 9 | def register(hook_name, fn=None, order=0): 10 | """ 11 | Register hook for ``hook_name``. Can be used as a decorator:: 12 | @register('hook_name') 13 | def my_hook(...): 14 | pass 15 | or as a function call:: 16 | def my_hook(...): 17 | pass 18 | register('hook_name', my_hook) 19 | """ 20 | 21 | # Pretend to be a decorator if fn is not supplied 22 | if fn is None: 23 | 24 | def decorator(fn): 25 | register(hook_name, fn, order=order) 26 | return fn 27 | 28 | return decorator 29 | 30 | if hook_name not in _hooks: 31 | _hooks[hook_name] = [] 32 | _hooks[hook_name].append((fn, order)) 33 | 34 | 35 | _searched_for_hooks = False 36 | 37 | 38 | def search_for_hooks(): 39 | global _searched_for_hooks 40 | if not _searched_for_hooks: 41 | list(get_app_submodules("wagtailstreamforms_hooks")) 42 | _searched_for_hooks = True 43 | 44 | 45 | def get_hooks(hook_name): 46 | """Return the hooks function sorted by their order.""" 47 | 48 | search_for_hooks() 49 | hooks = _hooks.get(hook_name, []) 50 | hooks = sorted(hooks, key=itemgetter(1)) 51 | fncs = [] 52 | builtin_hook_modules = ["wagtailstreamforms.wagtailstreamforms_hooks"] 53 | builtin_enabled = get_setting("ENABLE_BUILTIN_HOOKS") 54 | 55 | for fn, _ in hooks: 56 | # dont add the hooks if they have been disabled via the setting 57 | # this is so that they can be overridden 58 | if fn.__module__ in builtin_hook_modules and not builtin_enabled: 59 | continue 60 | fncs.append(fn) 61 | 62 | return fncs 63 | -------------------------------------------------------------------------------- /tests/fields/test_base_field.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from wagtailstreamforms import fields 4 | 5 | from ..test_case import AppTestCase 6 | 7 | 8 | class TestBaseField(AppTestCase): 9 | def test_options(self): 10 | class MyField(fields.BaseField): 11 | field_class = forms.CharField 12 | 13 | data = { 14 | "label": "field", 15 | "required": True, 16 | "default_value": "default", 17 | "help_text": "help", 18 | } 19 | 20 | options = MyField().get_options(data) 21 | 22 | self.assertEqual(options["label"], data["label"]) 23 | self.assertEqual(options["required"], data["required"]) 24 | self.assertEqual(options["initial"], data["default_value"]) 25 | self.assertEqual(options["help_text"], data["help_text"]) 26 | 27 | def test_no_form_class_raises_exception(self): 28 | class MyField(fields.BaseField): 29 | field_class = None 30 | 31 | with self.assertRaises(NotImplementedError) as ex: 32 | MyField().get_formfield({}) 33 | the_exception = ex.exception 34 | 35 | self.assertEqual(the_exception.args[0], "must provide a cls.field_class") 36 | 37 | def test_formfield(self): 38 | class MyField(fields.BaseField): 39 | field_class = forms.CharField 40 | 41 | data = { 42 | "label": "field", 43 | "required": True, 44 | "default_value": "default", 45 | "help_text": "help", 46 | } 47 | 48 | field = MyField().get_formfield(data) 49 | 50 | self.assertIsInstance(field, forms.CharField) 51 | 52 | self.assertEqual(field.label, data["label"]) 53 | self.assertEqual(field.required, data["required"]) 54 | self.assertEqual(field.initial, data["default_value"]) 55 | self.assertEqual(field.help_text, data["help_text"]) 56 | -------------------------------------------------------------------------------- /example/migrations/0003_alter_basicpage_body.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.13 on 2025-03-25 09:13 2 | 3 | import wagtail.blocks 4 | import wagtail.fields 5 | import wagtailstreamforms.blocks 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("example", "0002_advancedformsetting"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="basicpage", 18 | name="body", 19 | field=wagtail.fields.StreamField( 20 | [ 21 | ("rich_text", wagtail.blocks.RichTextBlock()), 22 | ( 23 | "form", 24 | wagtail.blocks.StructBlock( 25 | [ 26 | ("form", wagtailstreamforms.blocks.FormChooserBlock()), 27 | ( 28 | "form_action", 29 | wagtail.blocks.CharBlock( 30 | help_text='The form post action. "" or "." for the current page or a url', 31 | required=False, 32 | ), 33 | ), 34 | ( 35 | "form_reference", 36 | wagtailstreamforms.blocks.InfoBlock( 37 | help_text="This form will be given a unique reference once saved", 38 | required=False, 39 | ), 40 | ), 41 | ] 42 | ), 43 | ), 44 | ], 45 | use_json_field=True, 46 | ), 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /tests/templatetags/test_form.py: -------------------------------------------------------------------------------- 1 | from wagtailstreamforms.models import Form 2 | 3 | from ..test_case import AppTestCase 4 | 5 | 6 | class TemplateTagTests(AppTestCase): 7 | fixtures = ["test.json"] 8 | 9 | def setUp(self): 10 | self.form = Form.objects.get(pk=1) 11 | 12 | def test_render(self): 13 | fake_request = self.rf.get("/") 14 | html = self.render_template( 15 | """{% load streamforms_tags %}{% streamforms_form "basic-form" "some-ref" "." %}""", 16 | {"request": fake_request}, 17 | ) 18 | 19 | # Test critical elements that should be present 20 | self.assertIn("

Basic Form

", html) 21 | self.assertIn('action="."', html) 22 | self.assertIn('method="post"', html) 23 | self.assertIn('enctype="multipart/form-data"', html) 24 | 25 | # Check hidden fields 26 | self.assertIn(f'', html) 40 | 41 | def test_invalid_slug_renders_empty_content(self): 42 | fake_request = self.rf.get("/") 43 | html = self.render_template( 44 | """{% load streamforms_tags %}{% streamforms_form "non-existing-slug" "some-ref" "." %}""", 45 | {"request": fake_request}, 46 | ) 47 | 48 | self.assertHTMLEqual(html, "") 49 | -------------------------------------------------------------------------------- /wagtailstreamforms/streamfield.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from wagtail import blocks 3 | from wagtail.fields import StreamField 4 | 5 | from wagtailstreamforms.fields import BaseField, get_fields 6 | 7 | 8 | class FormFieldStreamBlock(blocks.StreamBlock): 9 | """Add all registered instances of BaseField's get_form_block method to the streamfield.""" 10 | 11 | def __init__(self, local_blocks=None, **kwargs) -> None: 12 | self._constructor_kwargs = kwargs 13 | 14 | # Note, this is calling BaseStreamBlock's super __init__, not FormFieldStreamBlock's. 15 | # We don't want BaseStreamBlock.__init__() to run, because it tries to assign to 16 | # self.child_blocks, which we've overridden with a @property. But we DO want 17 | # Block.__init__() to run. 18 | super(blocks.BaseStreamBlock, self).__init__() 19 | 20 | self._child_blocks = self.base_blocks.copy() 21 | 22 | for name, field_class in get_fields().items(): 23 | # ensure the field is a subclass of BaseField. 24 | if not issubclass(field_class, BaseField): 25 | raise ImproperlyConfigured( 26 | "'%s' must be a subclass of '%s'" % (field_class, BaseField) 27 | ) 28 | 29 | # assign the block 30 | block = field_class().get_form_block() 31 | block.set_name(name) 32 | self._child_blocks[name] = block 33 | 34 | self._dependencies = self._child_blocks.values() 35 | 36 | @property 37 | def child_blocks(self): 38 | return self._child_blocks 39 | 40 | @property 41 | def dependencies(self): 42 | return self._dependencies 43 | 44 | 45 | class FormFieldsStreamField(StreamField): 46 | def __init__(self, block_types, **kwargs) -> None: 47 | super().__init__(block_types, **kwargs) 48 | self.stream_block = FormFieldStreamBlock(block_types, required=not self.blank) 49 | -------------------------------------------------------------------------------- /tests/hooks/test_registering.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from wagtailstreamforms import hooks 4 | from wagtailstreamforms.wagtailstreamforms_hooks import save_form_submission_data 5 | 6 | from ..test_case import AppTestCase 7 | 8 | 9 | def test_hook(): 10 | pass 11 | 12 | 13 | class TestHookRegistering(AppTestCase): 14 | @classmethod 15 | def setUpClass(cls): 16 | hooks.register("test_hook_name", test_hook) 17 | 18 | @classmethod 19 | def tearDownClass(cls): 20 | del hooks._hooks["test_hook_name"] 21 | 22 | def test_before_hook(self): 23 | def before_hook(): 24 | pass 25 | 26 | with self.register_hook("test_hook_name", before_hook, order=-1): 27 | hook_fns = hooks.get_hooks("test_hook_name") 28 | self.assertEqual(hook_fns, [before_hook, test_hook]) 29 | 30 | def test_after_hook(self): 31 | def after_hook(): 32 | pass 33 | 34 | with self.register_hook("test_hook_name", after_hook, order=1): 35 | hook_fns = hooks.get_hooks("test_hook_name") 36 | self.assertEqual(hook_fns, [test_hook, after_hook]) 37 | 38 | 39 | class TestHookDefaults(AppTestCase): 40 | def test_default_hooks(self): 41 | hook_fns = hooks.get_hooks("process_form_submission") 42 | self.assertEqual(hook_fns, [save_form_submission_data]) 43 | 44 | @override_settings(WAGTAILSTREAMFORMS_ENABLE_BUILTIN_HOOKS=False) 45 | def test_builtins_can_be_disabled(self): 46 | hook_fns = hooks.get_hooks("process_form_submission") 47 | self.assertEqual(hook_fns, []) 48 | 49 | @override_settings(WAGTAILSTREAMFORMS_ENABLE_BUILTIN_HOOKS=False) 50 | def test_setting_only_removes_builtins(self): 51 | def custom_hook(): 52 | pass 53 | 54 | with self.register_hook("process_form_submission", custom_hook, order=1): 55 | hook_fns = hooks.get_hooks("process_form_submission") 56 | self.assertEqual(hook_fns, [custom_hook]) 57 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/list_submissions.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | 4 | 5 | 6 | 7 | 8 | {% if has_delete_permission %} 9 | 10 | 13 | 14 | {% endif %} 15 | 16 | {% if has_delete_permission %} 17 | 18 | {% endif %} 19 | {% for heading in data_headings %} 20 | 21 | {% endfor %} 22 | 23 | 24 | 25 | {% for row in data_rows %} 26 | 27 | {% if has_delete_permission %} 28 | 31 | {% endif %} 32 | {% for cell in row.fields %} 33 | 36 | {% endfor %} 37 | 39 | {% if row.files %} 40 | 41 | 42 | 51 | 52 | {% endif %} 53 | {% endfor %} 54 | 55 |
11 | 12 |
{{ heading }}
29 | 30 | 34 | {{ cell }} 35 | 38 |
{% trans 'Files' %} 43 |
    44 | {% for file in row.files %} 45 |
  • 46 | {{ file }} 47 |
  • 48 | {% endfor %} 49 |
50 |
56 |
-------------------------------------------------------------------------------- /tests/models/test_form_submission_file.py: -------------------------------------------------------------------------------- 1 | from django.db import models, transaction 2 | from django.test import TransactionTestCase 3 | 4 | from wagtailstreamforms.models import Form, FormSubmission, FormSubmissionFile 5 | 6 | from ..test_case import AppTestCase 7 | 8 | 9 | class ModelGenericTests(AppTestCase): 10 | def test_str(self): 11 | model = FormSubmissionFile(file=self.get_file()) 12 | self.assertEqual(model.__str__(), model.file.name) 13 | 14 | def test_ordering(self): 15 | self.assertEqual(FormSubmissionFile._meta.ordering, ["field", "file"]) 16 | 17 | 18 | class ModelFieldTests(AppTestCase): 19 | def test_submission(self): 20 | field = self.get_field(FormSubmissionFile, "submission") 21 | self.assertModelPKField(field, FormSubmission, models.CASCADE) 22 | 23 | def test_field(self): 24 | field = self.get_field(FormSubmissionFile, "field") 25 | self.assertModelField(field, models.CharField) 26 | 27 | def test_file(self): 28 | field = self.get_field(FormSubmissionFile, "file") 29 | self.assertModelField(field, models.FileField) 30 | 31 | 32 | class ModelPropertyTests(AppTestCase): 33 | def test_url(self): 34 | model = FormSubmissionFile(file=self.get_file()) 35 | self.assertEqual(model.url, model.file.url) 36 | 37 | 38 | class DeleteTests(TransactionTestCase): 39 | fixtures = ["test"] 40 | 41 | def test_files_are_deleted_on_commit(self): 42 | test_file = AppTestCase().get_file() 43 | name = None 44 | with transaction.atomic(): 45 | form = Form.objects.get(pk=1) 46 | submission = FormSubmission.objects.create(form=form, form_data={}) 47 | file = FormSubmissionFile.objects.create( 48 | submission=submission, field="field", file=test_file 49 | ) 50 | name = file.file.name 51 | self.assertTrue(file.file.storage.exists(name)) 52 | file.delete() 53 | self.assertTrue(file.file.storage.exists(name)) 54 | self.assertFalse(file.file.storage.exists(name)) 55 | -------------------------------------------------------------------------------- /wagtailstreamforms/forms.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django import forms 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from wagtailstreamforms.fields import get_fields 7 | 8 | 9 | class BaseForm(forms.Form): 10 | def __init__(self, *args, **kwargs): 11 | kwargs.setdefault("label_suffix", "") 12 | 13 | self.user = kwargs.pop("user", None) 14 | self.page = kwargs.pop("page", None) 15 | 16 | super().__init__(*args, **kwargs) 17 | 18 | 19 | class FormBuilder: 20 | def __init__(self, fields): 21 | self.fields = fields 22 | 23 | @property 24 | def formfields(self): 25 | """Return a list of form fields from the registered fields.""" 26 | 27 | formfields = OrderedDict() 28 | 29 | registered_fields = get_fields() 30 | 31 | for field in self.fields: 32 | field_type = field.get("type") 33 | field_value = field.get("value") 34 | 35 | # check we have the field 36 | if field_type not in registered_fields: 37 | raise AttributeError("Could not find a registered field of type %s" % field_type) 38 | 39 | # get the field 40 | registered_cls = registered_fields[field_type]() 41 | field_name = registered_cls.get_formfield_name(field_value) 42 | field_cls = registered_cls.get_formfield(field_value) 43 | formfields[field_name] = field_cls 44 | 45 | # add fields to uniquely identify the form 46 | formfields["form_id"] = forms.CharField(widget=forms.HiddenInput) 47 | formfields["form_reference"] = forms.CharField(widget=forms.HiddenInput) 48 | 49 | return formfields 50 | 51 | def get_form_class(self): 52 | return type(str("StreamformsForm"), (BaseForm,), self.formfields) 53 | 54 | 55 | class SelectDateForm(forms.Form): 56 | date_from = forms.DateTimeField( 57 | required=False, widget=forms.DateInput(attrs={"placeholder": _("Date from")}) 58 | ) 59 | date_to = forms.DateTimeField( 60 | required=False, widget=forms.DateInput(attrs={"placeholder": _("Date to")}) 61 | ) 62 | -------------------------------------------------------------------------------- /tests/forms/test_form_builder.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from wagtailstreamforms.fields import get_fields 4 | from wagtailstreamforms.forms import FormBuilder 5 | from wagtailstreamforms.models import Form 6 | 7 | from ..test_case import AppTestCase 8 | 9 | 10 | class FormBuilderTests(AppTestCase): 11 | fixtures = ["test.json"] 12 | 13 | def setUp(self): 14 | self.form = Form.objects.get(pk=1) 15 | 16 | def test_formfields(self): 17 | fields = self.form.get_form_fields() 18 | formfields = FormBuilder(fields).formfields 19 | for field in fields: 20 | self.assertIn(field["type"], formfields) 21 | 22 | def test_formfields__invalid_type(self): 23 | fields = [{"type": "foo", "value": {}}] 24 | with self.assertRaises(AttributeError) as ex: 25 | FormBuilder(fields).formfields 26 | self.assertEqual(ex.exception.args[0], "Could not find a registered field of type foo") 27 | 28 | def test_formfields__missing_label_in_value(self): 29 | fields = [{"type": "singleline", "value": {}}] 30 | with self.assertRaises(AttributeError) as ex: 31 | FormBuilder(fields).formfields 32 | self.assertEqual( 33 | ex.exception.args[0], 34 | "No label value can be determined for an instance of SingleLineTextField. " 35 | "Add a 'label' CharBlock() in your field's get_form_block() method to allow " 36 | "this to be specified by form editors. Or, override get_formfield_label() " 37 | "to return a different value.", 38 | ) 39 | 40 | def test_get_form_class(self): 41 | fields = self.form.get_form_fields() 42 | form_class = FormBuilder(fields).get_form_class() 43 | 44 | self.assertEqual(len(form_class().fields), 16) 45 | 46 | formfields = form_class().fields 47 | 48 | for name, field in get_fields().items(): 49 | self.assertIn(name, formfields) 50 | self.assertIsInstance(formfields[name], field().field_class) 51 | 52 | self.assertIsInstance(formfields["form_id"], forms.CharField) 53 | self.assertIsInstance(formfields["form_reference"], forms.CharField) 54 | -------------------------------------------------------------------------------- /tests/hooks/test_save_form_submission_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http import QueryDict 4 | 5 | from wagtailstreamforms.models import Form 6 | from wagtailstreamforms.wagtailstreamforms_hooks import save_form_submission_data 7 | 8 | from ..test_case import AppTestCase 9 | 10 | 11 | class TestHook(AppTestCase): 12 | def test_form(self): 13 | form = Form.objects.create( 14 | title="Form", 15 | template_name="streamforms/form_block.html", 16 | slug="form", 17 | fields=json.dumps( 18 | [ 19 | { 20 | "type": "singleline", 21 | "value": {"label": "singleline", "required": True}, 22 | "id": "9c46e208-e53a-4562-81f6-3fb3f34520f2", 23 | }, 24 | { 25 | "type": "multifile", 26 | "value": {"label": "multifile", "required": True}, 27 | "id": "91bac05f-754b-41a3-b038-ac7850e6f951", 28 | }, 29 | ] 30 | ), 31 | ) 32 | return form 33 | 34 | def test_saves_record_with_files(self): 35 | instance = self.test_form() 36 | 37 | data_dict = { 38 | "singleline": "text", 39 | "form_id": instance.pk, 40 | "form_reference": "some-ref", 41 | } 42 | files_dict = QueryDict(mutable=True) 43 | files_dict.update({"multifile": self.get_file()}) 44 | files_dict.update({"multifile": self.get_file()}) 45 | 46 | form_class = instance.get_form(data=data_dict, files=files_dict) 47 | 48 | assert form_class.is_valid() 49 | 50 | save_form_submission_data(instance, form_class) 51 | 52 | expected_data = { 53 | "singleline": "text", 54 | "multifile": "2 files", 55 | "form_id": str(instance.pk), 56 | "form_reference": "some-ref", 57 | } 58 | self.assertEqual(instance.get_submission_class().objects.count(), 1) 59 | self.assertDictEqual( 60 | json.loads(instance.get_submission_class().objects.all()[0].form_data), 61 | expected_data, 62 | ) 63 | self.assertEqual(instance.get_submission_class().objects.all()[0].files.count(), 2) 64 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-17 19:15 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import wagtailstreamforms.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [("wagtailstreamforms", "0001_initial")] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="HookSelectModel", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ( 28 | "hooks", 29 | wagtailstreamforms.fields.HookSelectField( 30 | blank=True, help_text="Some hooks", null=True 31 | ), 32 | ), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name="InvalidFormSettingsModel", 37 | fields=[ 38 | ( 39 | "id", 40 | models.AutoField( 41 | auto_created=True, 42 | primary_key=True, 43 | serialize=False, 44 | verbose_name="ID", 45 | ), 46 | ) 47 | ], 48 | ), 49 | migrations.CreateModel( 50 | name="ValidFormSettingsModel", 51 | fields=[ 52 | ( 53 | "id", 54 | models.AutoField( 55 | auto_created=True, 56 | primary_key=True, 57 | serialize=False, 58 | verbose_name="ID", 59 | ), 60 | ), 61 | ("name", models.CharField(max_length=255)), 62 | ("number", models.IntegerField()), 63 | ( 64 | "form", 65 | models.OneToOneField( 66 | on_delete=django.db.models.deletion.CASCADE, 67 | related_name="advanced_settings", 68 | to="wagtailstreamforms.Form", 69 | ), 70 | ), 71 | ], 72 | options={"abstract": False}, 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /docs/hooks.rst: -------------------------------------------------------------------------------- 1 | .. _hooks: 2 | 3 | Submission Hooks 4 | ================ 5 | 6 | Form submission hooks are used to process the cleaned_data of the form after a successful post. 7 | The only defined one is that to save the form submission data. 8 | 9 | .. literalinclude:: ../wagtailstreamforms/wagtailstreamforms_hooks.py 10 | :pyobject: save_form_submission_data 11 | 12 | You can disable this by setting ``WAGTAILSTREAMFORMS_ENABLE_BUILTIN_HOOKS=False`` in your ``settings.py`` 13 | 14 | Create your own hook 15 | -------------------- 16 | 17 | You can easily define additional hooks to perform a vast array of actions like 18 | 19 | - send a mail 20 | - save the data to a db 21 | - reply to the sender 22 | - etc 23 | 24 | Here is a simple example to send an email with the submission data. 25 | 26 | Create a ``wagtailstreamforms_hooks.py`` in the root of one of your apps and add the following. 27 | 28 | .. code-block:: python 29 | 30 | from django.conf import settings 31 | from django.core.mail import EmailMessage 32 | from django.template.defaultfilters import pluralize 33 | 34 | from wagtailstreamforms.hooks import register 35 | 36 | @register('process_form_submission') 37 | def email_submission(instance, form): 38 | """ Send an email with the submission. """ 39 | 40 | addresses = ['to@example.com'] 41 | content = ['Please see below submission\n', ] 42 | from_address = settings.DEFAULT_FROM_EMAIL 43 | subject = 'New Form Submission : %s' % instance.title 44 | 45 | # build up the email content 46 | for field, value in form.cleaned_data.items(): 47 | if field in form.files: 48 | count = len(form.files.getlist(field)) 49 | value = '{} file{}'.format(count, pluralize(count)) 50 | elif isinstance(value, list): 51 | value = ', '.join(value) 52 | content.append('{}: {}'.format(field, value)) 53 | content = '\n'.join(content) 54 | 55 | # create the email message 56 | email = EmailMessage( 57 | subject=subject, 58 | body=content, 59 | from_email=from_address, 60 | to=addresses 61 | ) 62 | 63 | # attach any files submitted 64 | for field in form.files: 65 | for file in form.files.getlist(field): 66 | file.seek(0) 67 | email.attach(file.name, file.read(), file.content_type) 68 | 69 | # finally send the email 70 | email.send(fail_silently=True) 71 | 72 | A new option will appear in the setup of the forms to run the above hook. The name of the option is taken from 73 | the function name so keep them unique to avoid confusion. The ``instance`` is the form class instance, the 74 | ``form`` is the processed valid form in the request. 75 | -------------------------------------------------------------------------------- /wagtailstreamforms/views/submission_delete.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.core.exceptions import PermissionDenied 3 | from django.http import HttpResponseRedirect 4 | from django.urls import reverse 5 | from django.utils.translation import ngettext 6 | from django.views.generic import DeleteView 7 | from wagtail_modeladmin.helpers import PermissionHelper 8 | 9 | from wagtailstreamforms.models import Form 10 | 11 | 12 | class SubmissionDeleteView(DeleteView): 13 | model = Form 14 | template_name = "streamforms/confirm_delete.html" 15 | 16 | @property 17 | def permission_helper(self): 18 | return PermissionHelper(model=self.model) 19 | 20 | def dispatch(self, request, *args, **kwargs): 21 | self.object = self.get_object() 22 | if not self.permission_helper.user_can_delete_obj(self.request.user, self.object): 23 | raise PermissionDenied 24 | return super().dispatch(request, *args, **kwargs) 25 | 26 | def get_object(self, queryset=None): 27 | obj = super().get_object(queryset) 28 | return obj 29 | 30 | def get_submissions(self): 31 | submission_ids = self.request.GET.getlist("selected-submissions") 32 | submission_class = self.object.get_submission_class() 33 | return submission_class._default_manager.filter(id__in=submission_ids) 34 | 35 | def get_context_data(self, **kwargs): 36 | context = super().get_context_data(**kwargs) 37 | context["submissions"] = self.get_submissions() 38 | return context 39 | 40 | def delete(self, request, *args, **kwargs): 41 | """ 42 | Django 4.0 uses FormMixin, so this logic has been moved to form_valid. 43 | 44 | This can be removed once Django 3.2 is no longer supported. 45 | """ 46 | success_url = self.get_success_url() 47 | submissions = self.get_submissions() 48 | count = submissions.count() 49 | submissions.delete() 50 | self.create_success_message(count) 51 | return HttpResponseRedirect(success_url) 52 | 53 | def form_valid(self, request, *args, **kwargs): 54 | success_url = self.get_success_url() 55 | submissions = self.get_submissions() 56 | count = submissions.count() 57 | submissions.delete() 58 | self.create_success_message(count) 59 | return HttpResponseRedirect(success_url) 60 | 61 | def create_success_message(self, count): 62 | messages.success( 63 | self.request, 64 | ngettext( 65 | "One submission has been deleted.", 66 | "%(count)d submissions have been deleted.", 67 | count, 68 | ) 69 | % {"count": count}, 70 | ) 71 | 72 | def get_success_url(self): 73 | return reverse("wagtailstreamforms:streamforms_submissions", kwargs={"pk": self.object.pk}) 74 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import wagtail 5 | from django.urls import reverse_lazy 6 | 7 | SECRET_KEY = "secret" 8 | 9 | INSTALLED_APPS = [ 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.sitemaps", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | # wagtail 18 | "wagtail", 19 | "wagtail.admin", 20 | "wagtail.documents", 21 | "wagtail.snippets", 22 | "wagtail.users", 23 | "wagtail.images", 24 | "wagtail.embeds", 25 | "wagtail.search", 26 | "wagtail.contrib.redirects", 27 | "wagtail.contrib.forms", 28 | "wagtail.sites", 29 | "wagtail.contrib.settings", 30 | "wagtail_modeladmin", 31 | "taggit", 32 | "wagtailstreamforms", 33 | "tests", 34 | ] 35 | 36 | MIDDLEWARE = [ 37 | "django.middleware.security.SecurityMiddleware", 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | "wagtail.contrib.redirects.middleware.RedirectMiddleware", 45 | ] 46 | 47 | DJANGO_VERSION = int(re.search("dj([0-9]+)", os.environ.get("TOX_ENV_NAME", "dj40")).group(1)) 48 | WAGTAIL_VERSION = int(re.search("wt([0-9]+)", os.environ.get("TOX_ENV_NAME", "wt216")).group(1)) 49 | 50 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "testdb"}} 51 | 52 | TEMPLATES = [ 53 | { 54 | "BACKEND": "django.template.backends.django.DjangoTemplates", 55 | "DIRS": [ 56 | # Standard template locations 57 | os.path.join(os.path.dirname(wagtail.__file__), "admin", "templates"), 58 | # For modeladmin in Wagtail 6.0+ 59 | os.path.join( 60 | os.path.dirname(os.path.dirname(wagtail.__file__)), 61 | "wagtail_modeladmin", 62 | "templates", 63 | ), 64 | # Fallback locations 65 | os.path.dirname(os.path.dirname(wagtail.__file__)), 66 | ], 67 | "APP_DIRS": True, 68 | "OPTIONS": { 69 | "context_processors": [ 70 | "django.template.context_processors.debug", 71 | "django.template.context_processors.request", 72 | "django.contrib.auth.context_processors.auth", 73 | "django.contrib.messages.context_processors.messages", 74 | ] 75 | }, 76 | } 77 | ] 78 | 79 | ROOT_URLCONF = "tests.urls" 80 | 81 | STATIC_URL = "/static/" 82 | 83 | LOGIN_URL = reverse_lazy("admin:login") 84 | 85 | WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL = "tests.ValidFormSettingsModel" 86 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 87 | -------------------------------------------------------------------------------- /tests/views/test_admin_list.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission, User 2 | from django.test import override_settings 3 | 4 | from ..test_case import AppTestCase 5 | 6 | 7 | class AdminListViewTestCase(AppTestCase): 8 | fixtures = ["test.json"] 9 | 10 | def setUp(self): 11 | self.user = User.objects.create_user("user", "user@test.com", "password", is_staff=True) 12 | self.access_admin = Permission.objects.get(codename="access_admin") 13 | self.add_perm = Permission.objects.get(codename="add_form") 14 | self.change_perm = Permission.objects.get(codename="change_form") 15 | self.delete_perm = Permission.objects.get(codename="delete_form") 16 | self.client.login(username="user", password="password") 17 | 18 | def test_get_responds(self): 19 | self.user.user_permissions.add(self.access_admin, self.add_perm) 20 | response = self.client.get("/cms/wagtailstreamforms/form/") 21 | self.assertEqual(response.status_code, 200) 22 | 23 | def test_copy_button_uses_add_perm(self): 24 | self.user.user_permissions.add(self.access_admin, self.change_perm) 25 | url = "/cms/wagtailstreamforms/form/" 26 | response = self.client.get(url) 27 | self.assertEqual(response.status_code, 200) 28 | self.assertNotIn('title="Copy this form">Copy', str(response.content)) 29 | 30 | self.user.user_permissions.add(self.access_admin, self.add_perm) 31 | 32 | response = self.client.get(url) 33 | self.assertEqual(response.status_code, 200) 34 | 35 | self.assertIn('title="Copy this form">Copy', str(response.content)) 36 | 37 | @override_settings(WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL="tests.ValidFormSettingsModel") 38 | def test_advanced_button_enabled_when_setup(self): 39 | url = "/cms/wagtailstreamforms/form/" 40 | expected_html = 'title="Advanced settings">Advanced' 41 | 42 | # Make sure user has basic admin access first 43 | self.user.user_permissions.add(self.access_admin) 44 | 45 | # Test with delete permission only 46 | self.user.user_permissions.add(self.delete_perm) 47 | response = self.client.get(url) 48 | self.assertEqual(response.status_code, 200) 49 | self.assertNotIn(expected_html, str(response.content)) 50 | 51 | # Test with add permission 52 | self.user.user_permissions.remove(self.delete_perm) 53 | self.user.user_permissions.add(self.add_perm) 54 | response = self.client.get(url) 55 | self.assertEqual(response.status_code, 200) 56 | self.assertIn(expected_html, str(response.content)) 57 | 58 | # Test with change permission 59 | self.user.user_permissions.remove(self.add_perm) 60 | self.user.user_permissions.add(self.change_perm) 61 | response = self.client.get(url) 62 | self.assertEqual(response.status_code, 200) 63 | self.assertIn(expected_html, str(response.content)) 64 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Wagtail Streamforms documentation master file, created by 2 | sphinx-quickstart on Sat Oct 14 14:40:45 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Wagtail Streamforms 7 | =================== 8 | 9 | Allows you to build forms in the CMS admin area and add them to any StreamField in your site. 10 | You can add your own fields along with the vast array of default fields which include the likes 11 | of file fields. Form submissions are controlled by hooks that you can add that process the forms cleaned data. 12 | Templates can be created which will then appear as choices when you build your form, 13 | allowing you to display and submit a form however you want. 14 | 15 | Backwards Compatibility 16 | ----------------------- 17 | 18 | .. important:: 19 | Please note that due to this package being virtually re-written for version 3, you cannot upgrade any existing 20 | older version of this package to version 3 and onwards. 21 | If you have an existing version installed less than 3 then you will need to completely remove it including 22 | tables and any migrations that were applied in the databases ``django_migrations`` table. 23 | 24 | Older versions: 25 | 26 | If you are using a version of wagtail 1.x, then the latest compatible version of this package is 1.6.3: 27 | 28 | .. code:: bash 29 | 30 | $ pip install wagtailstreamforms<2 31 | 32 | Other wise you must install a version of this package from 2 onwards: 33 | 34 | .. code:: bash 35 | 36 | $ pip install wagtailstreamforms>=2 37 | 38 | What else is included? 39 | ---------------------- 40 | 41 | * Each form is built using a StreamField. 42 | * Customise things like success and error messages, post submit redirects and more. 43 | * Forms are processed via a ``before_page_serve`` hook. Meaning there is no fuss like remembering to include a page mixin. 44 | * The hook can easily be disabled to provide the ability to create your own. 45 | * Form submissions are controlled via hooks meaning you can easily create things like emailing the submission which you can turn on and off on each form. 46 | * Fields can easily be added to the form from your own code such as Recaptcha or a Regex Field. 47 | * The default set of fields can easily be replaced to add things like widget attributes. 48 | * Form submissions are also listed by their form which you can filter by date and are ordered by newest first. 49 | * Files can also be submitted to the forms that are shown with the form submissions. 50 | * A form and its fields can easily be copied to a new form. 51 | * There is a template tag that can be used to render a form, in case you want it to appear outside a StreamField. 52 | 53 | .. toctree:: 54 | :maxdepth: 1 55 | :caption: Content 56 | 57 | installation 58 | usage 59 | templates 60 | fields 61 | advanced 62 | submission 63 | hooks 64 | permissions 65 | housekeeping 66 | settings 67 | contributors 68 | changelog 69 | screenshots 70 | -------------------------------------------------------------------------------- /wagtailstreamforms/views/copy.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import HttpResponseRedirect 5 | from django.utils.translation import gettext_lazy as _ 6 | from django.views.generic.detail import ( 7 | BaseDetailView, 8 | SingleObjectTemplateResponseMixin, 9 | ) 10 | from wagtail_modeladmin.helpers import PermissionHelper 11 | 12 | from wagtailstreamforms.models import Form 13 | from wagtailstreamforms.wagtail_hooks import FormURLHelper 14 | 15 | 16 | class CopyForm(forms.Form): 17 | title = forms.CharField(label=_("New title")) 18 | slug = forms.SlugField(label=_("New slug")) 19 | 20 | def clean_slug(self): 21 | slug = self.cleaned_data["slug"] 22 | if Form.objects.filter(slug=slug).exists(): 23 | raise forms.ValidationError("This slug is already in use") 24 | return slug 25 | 26 | 27 | class CopyFormView(SingleObjectTemplateResponseMixin, BaseDetailView): 28 | model = Form 29 | template_name = "streamforms/confirm_copy.html" 30 | success_message = _("Form '%s' copied to '%s'.") 31 | 32 | @property 33 | def permission_helper(self): 34 | return PermissionHelper(model=self.model) 35 | 36 | @property 37 | def url_helper(self): 38 | return FormURLHelper(model=self.model) 39 | 40 | def dispatch(self, request, *args, **kwargs): 41 | self.object = self.get_object() 42 | if not self.permission_helper.user_can_create(self.request.user): 43 | raise PermissionDenied 44 | return super().dispatch(request, *args, **kwargs) 45 | 46 | def get_object(self, queryset=None): 47 | obj = super().get_object(queryset) 48 | return obj 49 | 50 | def copy(self, request, *args, **kwargs): 51 | form = CopyForm(request.POST) 52 | 53 | if form.is_valid(): 54 | copied = self.object.copy() 55 | copied.title = form.cleaned_data["title"] 56 | copied.slug = form.cleaned_data["slug"] 57 | 58 | copied.save() 59 | 60 | self.create_success_message(copied) 61 | 62 | return HttpResponseRedirect(self.get_success_url()) 63 | 64 | context = self.get_context_data(object=self.object) 65 | context["form"] = form 66 | 67 | return self.render_to_response(context) 68 | 69 | def get(self, request, *args, **kwargs): 70 | context = self.get_context_data(object=self.object) 71 | context["form"] = CopyForm(initial={"title": self.object.title, "slug": self.object.slug}) 72 | 73 | return self.render_to_response(context) 74 | 75 | def post(self, request, *args, **kwargs): 76 | return self.copy(request, *args, **kwargs) 77 | 78 | def create_success_message(self, copied): 79 | success_message = self.success_message % (self.object, copied) 80 | messages.success(self.request, success_message) 81 | 82 | def get_success_url(self): 83 | return self.url_helper.index_url 84 | -------------------------------------------------------------------------------- /example/wagtailstreamforms_fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.models import User 3 | from django_recaptcha.fields import ReCaptchaField 4 | from wagtail import blocks 5 | 6 | from wagtailstreamforms.fields import BaseField, register 7 | 8 | 9 | @register("recaptcha") 10 | class ReCaptchaField(BaseField): 11 | field_class = ReCaptchaField 12 | icon = "success" 13 | label = "ReCAPTCHA field" 14 | 15 | def get_options(self, block_value): 16 | options = super().get_options(block_value) 17 | options.update({"required": True}) 18 | return options 19 | 20 | def get_form_block(self): 21 | return blocks.StructBlock( 22 | [ 23 | ("label", blocks.CharBlock()), 24 | ("help_text", blocks.CharBlock(required=False)), 25 | ], 26 | icon=self.icon, 27 | label=self.label, 28 | ) 29 | 30 | 31 | @register("regex_validated") 32 | class RegexValidatedField(BaseField): 33 | field_class = forms.RegexField 34 | label = "Regex field" 35 | 36 | def get_options(self, block_value): 37 | options = super().get_options(block_value) 38 | options.update( 39 | { 40 | "regex": block_value.get("regex"), 41 | "error_messages": {"invalid": block_value.get("error_message")}, 42 | } 43 | ) 44 | return options 45 | 46 | def get_regex_choices(self): 47 | return ( 48 | ("(.*?)", "Any"), 49 | ("^[a-zA-Z0-9]+$", "Letters and numbers only"), 50 | ) 51 | 52 | def get_form_block(self): 53 | return blocks.StructBlock( 54 | [ 55 | ("label", blocks.CharBlock()), 56 | ("help_text", blocks.CharBlock(required=False)), 57 | ("required", blocks.BooleanBlock(required=False)), 58 | ("regex", blocks.ChoiceBlock(choices=self.get_regex_choices())), 59 | ("error_message", blocks.CharBlock()), 60 | ("default_value", blocks.CharBlock(required=False)), 61 | ], 62 | icon=self.icon, 63 | label=self.label, 64 | ) 65 | 66 | 67 | @register("user") 68 | class UserChoiceField(BaseField): 69 | field_class = forms.ModelChoiceField 70 | icon = "user" 71 | label = "User dropdown field" 72 | 73 | @staticmethod 74 | def get_queryset(): 75 | return User.objects.all() 76 | 77 | def get_options(self, block_value): 78 | options = super().get_options(block_value) 79 | options.update({"queryset": self.get_queryset()}) 80 | return options 81 | 82 | def get_form_block(self): 83 | return blocks.StructBlock( 84 | [ 85 | ("label", blocks.CharBlock()), 86 | ("help_text", blocks.CharBlock(required=False)), 87 | ("required", blocks.BooleanBlock(required=False)), 88 | ], 89 | icon=self.icon, 90 | label=self.label, 91 | ) 92 | -------------------------------------------------------------------------------- /tests/fixtures/test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "wagtailstreamforms.form", 4 | "pk": 1, 5 | "fields": { 6 | "title": "Basic Form", 7 | "slug": "basic-form", 8 | "template_name": "streamforms/form_block.html", 9 | "fields": "[{\"type\": \"singleline\", \"value\": {\"label\": \"singleline\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"9c46e208-e53a-4562-81f6-3fb3f34520f2\"}, {\"type\": \"multiline\", \"value\": {\"label\": \"multiline\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"91bac05f-754b-41a3-b038-ac7850e6f951\"}, {\"type\": \"date\", \"value\": {\"label\": \"date\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"f73034ea-86f1-491d-9615-1852e6623b30\"}, {\"type\": \"datetime\", \"value\": {\"label\": \"datetime\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"e135820c-aafd-4b5c-9cdb-041ac18ca50f\"}, {\"type\": \"email\", \"value\": {\"label\": \"email\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"c8210a55-01ff-47bb-8ac0-635d58d855dd1\"}, {\"type\": \"url\", \"value\": {\"label\": \"url\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"33814d84-dd38-4a42-a864-85adffcbf7ae\"}, {\"type\": \"number\", \"value\": {\"label\": \"number\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"bdbddb0e-a5b7-481c-80ad-0d615df17410\"}, {\"type\": \"dropdown\", \"value\": {\"label\": \"dropdown\", \"help_text\": \"Help\", \"required\": true, \"empty_label\": \"\", \"choices\": [\"Option 1\", \"Option 2\", \"Option 3\"]}, \"id\": \"3a3bdb64-3048-41c5-b418-4ea598c086cb\"}, {\"type\": \"multiselect\", \"value\": {\"label\": \"multiselect\", \"help_text\": \"Help\", \"required\": true, \"choices\": [\"Option 1\", \"Option 2\", \"Option 3\"]}, \"id\": \"2bf277bb-ccd3-4797-841d-49e42d0ac84e\"}, {\"type\": \"radio\", \"value\": {\"label\": \"radio\", \"help_text\": \"Help\", \"required\": true, \"choices\": [\"Option 1\", \"Option 2\", \"Option 3\"]}, \"id\": \"db5e500c-ed17-4834-8987-99b41d4a57f9\"}, {\"type\": \"checkboxes\", \"value\": {\"label\": \"checkboxes\", \"help_text\": \"Help\", \"required\": true, \"choices\": [\"Option 1\", \"Option 2\", \"Option 3\"]}, \"id\": \"870e40f6-ee52-4581-81ea-a178d8715a51\"}, {\"type\": \"checkbox\", \"value\": {\"label\": \"checkbox\", \"help_text\": \"Help\", \"required\": true}, \"id\": \"61682894-60ea-43be-93ac-d3a7f1a73d70\"}, {\"type\": \"hidden\", \"value\": {\"label\": \"hidden\", \"help_text\": \"Help\", \"required\": true, \"default_value\": \"\"}, \"id\": \"2a1b98f2-1968-489f-803b-d40a7d54f376\"}, {\"type\": \"singlefile\", \"value\": {\"label\": \"singlefile\", \"help_text\": \"Help\", \"required\": true}, \"id\": \"c1f54890-1020-42cb-becc-e88b688004ae\"}, {\"type\": \"multifile\", \"value\": {\"label\": \"multifile\", \"help_text\": \"Help\", \"required\": true}, \"id\": \"b951d149-c02a-4773-b173-a9d82c504630\"}]", 10 | "submit_button_text": "Submit", 11 | "success_message": "Thankyou", 12 | "error_message": "Oops", 13 | "post_redirect_page": null, 14 | "process_form_submission_hooks": "[]" 15 | } 16 | } 17 | ] -------------------------------------------------------------------------------- /tests/test_case.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import contextmanager 3 | from importlib import import_module, reload 4 | 5 | from django.core.files.uploadedfile import SimpleUploadedFile 6 | from django.db import connection, models 7 | from django.template import Context, Template 8 | from django.test import TestCase 9 | from django.test.client import RequestFactory 10 | 11 | 12 | class AppTestCase(TestCase): 13 | @property 14 | def rf(self): 15 | return RequestFactory() 16 | 17 | @staticmethod 18 | def setupModels(*models): 19 | """Create test models""" 20 | with connection.schema_editor(atomic=True) as schema_editor: 21 | for model in models: 22 | schema_editor.create_model(model) 23 | 24 | def get_field(self, modelClass, name): 25 | return modelClass._meta.get_field(name) 26 | 27 | def get_file(self): 28 | return SimpleUploadedFile("file.mp4", b"file_content", content_type="video/mp4") 29 | 30 | @contextmanager 31 | def register_field(self, field_type, cls): 32 | from wagtailstreamforms import fields 33 | 34 | fields.register(field_type, cls) 35 | try: 36 | yield 37 | finally: 38 | fields._fields[field_type].remove(cls) 39 | 40 | @contextmanager 41 | def register_hook(self, hook_name, fn, order=0): 42 | from wagtailstreamforms import hooks 43 | 44 | hooks.register(hook_name, fn, order) 45 | try: 46 | yield 47 | finally: 48 | hooks._hooks[hook_name].remove((fn, order)) 49 | 50 | def reload_module(self, path): 51 | if path in sys.modules: 52 | reload(sys.modules[path]) 53 | return import_module(path) 54 | 55 | def render_template(self, string, context=None): 56 | context = context or {} 57 | context = Context(context) 58 | return Template(string).render(context) 59 | 60 | _non_blankable_fields = [models.BooleanField] 61 | 62 | def assertModelField(self, field, expected_class, null=False, blank=False, default=None): 63 | self.assertEqual(field.__class__, expected_class) 64 | self.assertEqual(field.null, null) 65 | if expected_class not in self._non_blankable_fields: 66 | self.assertEqual(field.blank, blank) 67 | 68 | if default: 69 | self.assertEqual(field.default, default) 70 | 71 | def assertModelDecimalField(self, field, max_digits, decimal_places, null=False, blank=False): 72 | self.assertEqual(field.__class__, models.DecimalField) 73 | self.assertEqual(field.max_digits, max_digits) 74 | self.assertEqual(field.decimal_places, decimal_places) 75 | self.assertEqual(field.null, null) 76 | self.assertEqual(field.blank, blank) 77 | 78 | def assertModelPKField( 79 | self, field, rel_to, on_delete, null=False, blank=False, related_name=None 80 | ): 81 | self.assertEqual(field.__class__, models.ForeignKey) 82 | self.assertEqual(field.remote_field.model, rel_to) 83 | self.assertEqual(field.remote_field.on_delete, on_delete) 84 | self.assertEqual(field.null, null) 85 | self.assertEqual(field.blank, blank) 86 | 87 | if related_name: 88 | self.assertEqual(field.remote_field.related_name, related_name) 89 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at support@accentdesign.co.uk. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /wagtailstreamforms/blocks.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.utils.functional import cached_property 4 | from django.utils.safestring import SafeText, mark_safe 5 | from django.utils.translation import gettext_lazy as _ 6 | from wagtail import blocks 7 | 8 | 9 | class InfoBlock(blocks.CharBlock): 10 | def render_form(self, value, prefix="", errors=None) -> SafeText: 11 | field = self.field 12 | shown_value = value if value else field.help_text 13 | return mark_safe('
%s
' % shown_value) 14 | 15 | 16 | class FormChooserBlock(blocks.ChooserBlock): 17 | @cached_property 18 | def target_model(self): 19 | from .models import Form 20 | 21 | return Form 22 | 23 | @cached_property 24 | def widget(self): 25 | from .wagtail_hooks import WagtailStreamFormsChooser 26 | 27 | return WagtailStreamFormsChooser() 28 | 29 | def get_form_state(self, value: dict): 30 | return self.widget.get_value_data(value) 31 | 32 | 33 | class WagtailFormBlock(blocks.StructBlock): 34 | form = FormChooserBlock() 35 | form_action = blocks.CharBlock( 36 | required=False, 37 | help_text=_('The form post action. "" or "." for the current page or a url'), 38 | ) 39 | form_reference = InfoBlock( 40 | required=False, 41 | help_text=_("This form will be given a unique reference once saved"), 42 | ) 43 | 44 | class Meta: 45 | icon = "form" 46 | template = None 47 | 48 | def render(self, value: dict, context: dict | None = None) -> str | SafeText: 49 | form = value.get("form") 50 | 51 | # check if we have a form, as they can be deleted, and we dont want to break the site with 52 | # a none template value 53 | if form: 54 | self.meta.template = form.template_name 55 | else: 56 | self.meta.template = "streamforms/non_existent_form.html" 57 | 58 | return super().render(value, context) 59 | 60 | def get_context(self, value: dict, parent_context: dict | None = None) -> dict: 61 | context = super().get_context(value, parent_context) 62 | 63 | form = value.get("form") 64 | form_reference = value.get("form_reference") 65 | 66 | if form: 67 | # check the context for an invalid form submitted to the page. 68 | # Use that instead if it has the same unique form_reference number 69 | invalid_form_reference = context.get("invalid_stream_form_reference") 70 | invalid_form = context.get("invalid_stream_form") 71 | 72 | if ( 73 | invalid_form_reference 74 | and invalid_form 75 | and invalid_form_reference == form_reference 76 | ): 77 | context["form"] = invalid_form 78 | else: 79 | context["form"] = form.get_form( 80 | initial={"form_id": form.id, "form_reference": form_reference} 81 | ) 82 | 83 | return context 84 | 85 | def clean(self, value: dict) -> dict: 86 | result = super().clean(value) 87 | 88 | # set to a new uuid so we can ensure we can identify this form 89 | # against other forms of the same type in the page 90 | if not result.get("form_reference"): 91 | result["form_reference"] = uuid.uuid4() 92 | 93 | return result 94 | -------------------------------------------------------------------------------- /docs/templates.rst: -------------------------------------------------------------------------------- 1 | Templates 2 | ========= 3 | 4 | You can create your own form templates to use against any form in the system, providing a vast array of ways to 5 | create, style and submit your forms. 6 | 7 | The default template located at ``streamforms/form_block.html`` can be seen below: 8 | 9 | .. literalinclude:: ../wagtailstreamforms/templates/streamforms/form_block.html 10 | 11 | .. note:: It is important here to keep the hidden fields as the form will have some in order to process correctly. 12 | 13 | Once you have created you own you will need to add it to the list of available templates within the form builder. 14 | This is as simple as adding it to the ``WAGTAILSTREAMFORMS_FORM_TEMPLATES`` in settings as below. 15 | 16 | .. code-block:: python 17 | 18 | # this includes the default template in the package and an additional custom template. 19 | 20 | WAGTAILSTREAMFORMS_FORM_TEMPLATES = ( 21 | ('streamforms/form_block.html', 'Default Form Template'), # default 22 | ('app/custom_form_template.html', 'Custom Form Template'), 23 | ) 24 | 25 | You are not required to use the default template, its only there as a guideline to what is required and provide a fully working 26 | package out of the box. If you dont want it just remove it from the ``WAGTAILSTREAMFORMS_FORM_TEMPLATES`` setting. 27 | 28 | Rendering your StreamField 29 | -------------------------- 30 | 31 | It is important to ensure the request is in the context of your page to do this iterate over your StreamField block using 32 | wagtails ``include_block`` template tag. 33 | 34 | .. code-block:: python 35 | 36 | {% load wagtailcore_tags %} 37 | 38 | {% for block in page.body %} 39 | {% include_block block %} 40 | {% endfor %} 41 | 42 | DO NOT use the short form method of ``{{ block }}`` as described `here `_ 43 | as you will get CSRF verification failures. 44 | 45 | Deleted forms 46 | ------------- 47 | 48 | In the event of a form being deleted which is still in use in a streamfield the following template will be rendered 49 | in its place: 50 | 51 | ``streamforms/non_existent_form.html`` 52 | 53 | .. literalinclude:: ../wagtailstreamforms/templates/streamforms/non_existent_form.html 54 | 55 | You can override this by putting a copy of the template in you own project using the same 56 | path under a templates directory ie ``app/templates/streamforms/non_existent_form.html``. As long as the app is before 57 | ``wagtailstreamforms`` in ``INSTALLED_APPS`` it will use your template instead. 58 | 59 | Messaging 60 | --------- 61 | 62 | When the ``success`` or ``error`` message options are completed in the form builder and upon submission of the form 63 | a message is sent to django's messaging framework. 64 | 65 | You will need to add ``django.contrib.messages`` to your ``INSTALLED_APPS`` setting: 66 | 67 | .. code-block:: python 68 | 69 | INSTALLED_APPS = [ 70 | ... 71 | 'django.contrib.messages' 72 | ... 73 | ] 74 | 75 | 76 | To display these in your site you will need to include somewhere in your page's markup a snippet 77 | similar to the following: 78 | 79 | :: 80 | 81 | {% if messages %} 82 |
    83 | {% for message in messages %} 84 | {{ message }} 85 | {% endfor %} 86 |
87 | {% endif %} 88 | 89 | Any message from the form will then be displayed. 90 | -------------------------------------------------------------------------------- /tests/views/test_copy.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission, User 2 | from django.urls import reverse 3 | 4 | from wagtailstreamforms.models import Form 5 | from wagtailstreamforms.wagtail_hooks import FormURLHelper 6 | 7 | from ..test_case import AppTestCase 8 | 9 | 10 | class CopyViewTestCase(AppTestCase): 11 | fixtures = ["test.json"] 12 | 13 | def setUp(self): 14 | User.objects.create_superuser("user", "user@test.com", "password") 15 | self.form = Form.objects.get(pk=1) 16 | 17 | self.copy_url = reverse("wagtailstreamforms:streamforms_copy", kwargs={"pk": self.form.pk}) 18 | self.invalid_copy_url = reverse("wagtailstreamforms:streamforms_copy", kwargs={"pk": 100}) 19 | 20 | self.client.login(username="user", password="password") 21 | 22 | def test_get_responds(self) -> None: 23 | response = self.client.get(self.copy_url) 24 | self.assertEqual(response.status_code, 200) 25 | 26 | def test_invalid_form_responds(self) -> None: 27 | Form.objects.create(title="Existing Form", slug="unique-slug") 28 | 29 | response = self.client.post( 30 | self.copy_url, 31 | {"slug": "new-copy-slug"}, 32 | follow=True, 33 | ) 34 | 35 | self.assertIn("This field is required.", response.content.decode()) 36 | 37 | def test_invalid_form_slug_in_use_error(self) -> None: 38 | Form.objects.create(title="Existing Form", slug="existing-form") 39 | 40 | response = self.client.post( 41 | self.copy_url, 42 | {"title": "new copy", "slug": "existing-form"}, 43 | follow=True, 44 | ) 45 | self.assertIn("This slug is already in use", response.content.decode()) 46 | 47 | def test_invalid_pk_raises_404(self) -> None: 48 | response = self.client.get(self.invalid_copy_url) 49 | self.assertEqual(response.status_code, 404) 50 | 51 | def test_post_copies(self) -> None: 52 | self.client.post(self.copy_url, data={"title": "new copy", "slug": "new-slug"}) 53 | self.assertEqual(Form.objects.count(), 2) 54 | 55 | def test_post_redirects(self) -> None: 56 | response = self.client.post(self.copy_url, data={"title": "new copy", "slug": "new-slug"}) 57 | url_helper = FormURLHelper(model=Form) 58 | self.assertRedirects(response, url_helper.index_url) 59 | 60 | 61 | class CopyViewPermissionTestCase(AppTestCase): 62 | fixtures = ["test.json"] 63 | 64 | def setUp(self) -> None: 65 | self.user = User.objects.create_user("user", "user@test.com", "password") 66 | form = Form.objects.get(pk=1) 67 | self.copy_url = reverse("wagtailstreamforms:streamforms_copy", kwargs={"pk": form.pk}) 68 | 69 | def test_no_user_no_access(self) -> None: 70 | response = self.client.get(self.copy_url) 71 | self.assertEqual(response.status_code, 302) 72 | self.assertTrue(response.url.startswith("/cms/login/?next=/cms/wagtailstreamforms")) 73 | 74 | def test_user_with_no_perm_no_access(self) -> None: 75 | access_admin = Permission.objects.get(codename="access_admin") 76 | self.user.user_permissions.add(access_admin) 77 | 78 | self.client.login(username="user", password="password") 79 | 80 | response = self.client.get(self.copy_url) 81 | self.assertEqual(response.status_code, 302) 82 | self.assertTrue(response.url.startswith("/cms/")) 83 | 84 | def test_user_with_add_perm_has_access(self) -> None: 85 | access_admin = Permission.objects.get(codename="access_admin") 86 | form_perm = Permission.objects.get(codename="add_form") 87 | self.user.user_permissions.add(access_admin, form_perm) 88 | self.user.is_staff = True 89 | self.user.save() 90 | 91 | self.client.login(username="user", password="password") 92 | 93 | response = self.client.get(self.copy_url) 94 | self.assertEqual(response.status_code, 200) 95 | -------------------------------------------------------------------------------- /docs/submission.rst: -------------------------------------------------------------------------------- 1 | Submission Methods 2 | ================== 3 | 4 | Form submissions are handled by the means of a wagtail ``before_serve_page`` hook. The built in hook at 5 | ``wagtailstreamforms.wagtail_hooks.process_form`` looks for a form in the post request, 6 | and either: 7 | 8 | * processes it redirecting back to the current page or defined page in the form setup. 9 | * or renders the current page with any validation error. 10 | 11 | If no form was posted then the page serves in the usual manner. 12 | 13 | .. note:: Currently the hook expects the form to be posting to the same page it exists on. 14 | 15 | .. _rst_provide_own_submission: 16 | 17 | Providing your own submission method 18 | ------------------------------------ 19 | 20 | If you do not want the current hook to be used you need to disable it by setting the 21 | ``WAGTAILSTREAMFORMS_ENABLE_FORM_PROCESSING`` to ``False`` in your settings: 22 | 23 | .. code-block:: python 24 | 25 | WAGTAILSTREAMFORMS_ENABLE_FORM_PROCESSING = False 26 | 27 | With this set no forms will be processed of any kind and you are free to process them how you feel fit. 28 | 29 | A basic hook example 30 | ~~~~~~~~~~~~~~~~~~~~ 31 | 32 | .. literalinclude:: ../wagtailstreamforms/wagtail_hooks.py 33 | :pyobject: process_form 34 | 35 | Supporting ajax requests 36 | ~~~~~~~~~~~~~~~~~~~~~~~~ 37 | 38 | The only addition here from the basic example is just the ``if request.is_ajax:`` and the ``JsonResponse`` parts. 39 | 40 | We are just making it respond with this if the request was ajax. 41 | 42 | .. code-block:: python 43 | 44 | @hooks.register('before_serve_page') 45 | def process_form(page, request, *args, **kwargs): 46 | """ Process the form if there is one, if not just continue. """ 47 | 48 | if request.method == 'POST': 49 | form_def = get_form_instance_from_request(request) 50 | 51 | if form_def: 52 | form = form_def.get_form(request.POST, request.FILES, page=page, user=request.user) 53 | context = page.get_context(request, *args, **kwargs) 54 | 55 | if form.is_valid(): 56 | # process the form submission 57 | form_def.process_form_submission(form) 58 | 59 | # if the request is_ajax then just return a success message 60 | if request.is_ajax(): 61 | return JsonResponse({'message': form_def.success_message or 'success'}) 62 | 63 | # insert code to serve page if not ajax (as original) 64 | 65 | else: 66 | # if the request is_ajax then return an error message and the form errors 67 | if request.is_ajax(): 68 | return JsonResponse({ 69 | 'message': form_def.error_message or 'error', 70 | 'errors': form.errors 71 | }) 72 | 73 | # insert code to serve page if not ajax (as original) 74 | 75 | Add some javascript somewhere to process the form via ajax: 76 | 77 | :: 78 | 79 |
...
80 | 81 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from os.path import abspath, dirname, join 3 | 4 | from django.urls import reverse_lazy 5 | 6 | SITE_DIR = dirname(abspath(__file__)) 7 | 8 | 9 | # Security 10 | 11 | SECRET_KEY = environ.get("SECRET_KEY", "dummy") 12 | 13 | DEBUG = True 14 | 15 | ALLOWED_HOSTS = [] + environ.get("ALLOWED_HOSTS", "").split(",") 16 | 17 | 18 | # Application definition 19 | 20 | INSTALLED_APPS = [ 21 | "django.contrib.admin", 22 | "django.contrib.auth", 23 | "django.contrib.sitemaps", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "django.contrib.messages", 27 | "django.contrib.staticfiles", 28 | # wagtail 29 | "wagtail", 30 | "wagtail.admin", 31 | "wagtail.documents", 32 | "wagtail.snippets", 33 | "wagtail.users", 34 | "wagtail.images", 35 | "wagtail.embeds", 36 | "wagtail.search", 37 | "wagtail.contrib.redirects", 38 | "wagtail.sites", 39 | "wagtail_modeladmin", 40 | "wagtail.contrib.settings", 41 | "wagtail.contrib.search_promotions", 42 | "django_recaptcha", 43 | "taggit", 44 | # app specific 45 | "wagtailstreamforms", 46 | "example", 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "django.middleware.csrf.CsrfViewMiddleware", 54 | "django.contrib.auth.middleware.AuthenticationMiddleware", 55 | "django.contrib.messages.middleware.MessageMiddleware", 56 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 57 | "wagtail.contrib.redirects.middleware.RedirectMiddleware", 58 | ] 59 | 60 | ROOT_URLCONF = "example.urls" 61 | 62 | TEMPLATES = [ 63 | { 64 | "BACKEND": "django.template.backends.django.DjangoTemplates", 65 | "DIRS": [ 66 | join(SITE_DIR, "templates"), 67 | ], 68 | "APP_DIRS": True, 69 | "OPTIONS": { 70 | "context_processors": [ 71 | "django.template.context_processors.debug", 72 | "django.template.context_processors.request", 73 | "django.contrib.auth.context_processors.auth", 74 | "django.contrib.messages.context_processors.messages", 75 | "wagtail.contrib.settings.context_processors.settings", 76 | ], 77 | }, 78 | }, 79 | ] 80 | 81 | WSGI_APPLICATION = "example.wsgi.application" 82 | 83 | 84 | # Database 85 | 86 | DATABASES = { 87 | "default": { 88 | "ENGINE": "django.db.backends.postgresql_psycopg2", 89 | "HOST": environ.get("RDS_HOSTNAME"), 90 | "PORT": environ.get("RDS_PORT"), 91 | "NAME": environ.get("RDS_DB_NAME"), 92 | "USER": environ.get("RDS_USERNAME"), 93 | "PASSWORD": environ.get("RDS_PASSWORD"), 94 | } 95 | } 96 | 97 | 98 | # Email 99 | 100 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 101 | DEFAULT_FROM_EMAIL = "Django " 102 | 103 | 104 | # Authentication 105 | 106 | AUTH_PASSWORD_VALIDATORS = [] 107 | 108 | LOGIN_URL = reverse_lazy("admin:login") 109 | LOGIN_REDIRECT_URL = LOGOUT_REDIRECT_URL = "/" 110 | 111 | 112 | # Internationalization 113 | 114 | LANGUAGE_CODE = "en" 115 | LANGUAGES = [ 116 | ("en", "English"), 117 | ] 118 | 119 | TIME_ZONE = "UTC" 120 | 121 | USE_I18N = True 122 | 123 | USE_L10N = True 124 | 125 | USE_TZ = True 126 | 127 | 128 | # Static files (CSS, JavaScript, Images) 129 | 130 | STATICFILES_DIRS = [ 131 | join(SITE_DIR, "static"), 132 | ] 133 | STATIC_URL = "/static/" 134 | 135 | MEDIA_ROOT = join(SITE_DIR, "media") 136 | MEDIA_URL = "/media/" 137 | 138 | 139 | # Wagtail 140 | 141 | WAGTAIL_SITE_NAME = "example.com" 142 | WAGTAILADMIN_BASE_URL = "/" 143 | 144 | # Forms 145 | 146 | WAGTAILSTREAMFORMS_ADVANCED_SETTINGS_MODEL = "example.AdvancedFormSetting" 147 | 148 | 149 | # ReCAPTCHA 150 | 151 | # developer keys 152 | RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" 153 | RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" 154 | NOCAPTCHA = True 155 | SILENCED_SYSTEM_CHECKS = ["django_recaptcha.recaptcha_test_key_error"] 156 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Wagtail StreamForms 2 | =================== 3 | 4 | |tests| |Codecov| |pypi| |github| 5 | 6 | Allows you to build forms in the CMS admin area and add them to any StreamField in your site. 7 | You can add your own fields along with the vast array of default fields which include the likes 8 | of file fields. Form submissions are controlled by hooks that you can add that process the forms cleaned data. 9 | Templates can be created which will then appear as choices when you build your form, 10 | allowing you to display and submit a form however you want. 11 | 12 | Backwards Compatibility 13 | ----------------------- 14 | 15 | Please note that due to this package being virtually re-written for version 3, you cannot upgrade any existing 16 | older version of this package to version 3 and onwards. 17 | If you have an existing version installed less than 3 then you will need to completely remove it including 18 | tables and any migrations that were applied in the databases ``django_migrations`` table. 19 | 20 | Older versions: 21 | 22 | If you are using a version of wagtail 1.x, then the latest compatible version of this package is 1.6.3: 23 | 24 | .. code:: bash 25 | 26 | $ pip install wagtailstreamforms<2 27 | 28 | Other wise you must install a version of this package from 2 onwards: 29 | 30 | .. code:: bash 31 | 32 | $ pip install wagtailstreamforms>=2 33 | 34 | What else is included? 35 | ---------------------- 36 | 37 | * Each form is built using a StreamField. 38 | * Customise things like success and error messages, post submit redirects and more. 39 | * Forms are processed via a ``before_page_serve`` hook. Meaning there is no fuss like remembering to include a page mixin. 40 | * The hook can easily be disabled to provide the ability to create your own. 41 | * Form submissions are controlled via hooks meaning you can easily create things like emailing the submission which you can turn on and off on each form. 42 | * Fields can easily be added to the form from your own code such as Recaptcha or a Regex Field. 43 | * The default set of fields can easily be replaced to add things like widget attributes. 44 | * You can define a model that will allow you to save additional settings for each form. 45 | * Form submissions are also listed by their form which you can filter by date and are ordered by newest first. 46 | * Files can also be submitted to the forms that are shown with the form submissions. 47 | * A form and its fields can easily be copied to a new form. 48 | * There is a template tag that can be used to render a form, in case you want it to appear outside a StreamField. 49 | 50 | Documentation 51 | ------------- 52 | 53 | Can be found on `readthedocs `_. 54 | 55 | Screenshots 56 | ----------- 57 | 58 | .. figure:: http://wagtailstreamforms.readthedocs.io/en/latest/_images/screen_1.png 59 | :width: 728 px 60 | 61 | Example Front End 62 | 63 | .. figure:: http://wagtailstreamforms.readthedocs.io/en/latest/_images/screen_3.png 64 | :width: 728 px 65 | 66 | Form Fields Selection 67 | 68 | Example site with docker 69 | ------------------------ 70 | 71 | Clone the repo 72 | 73 | .. code:: bash 74 | 75 | $ git clone https://github.com/labd/wagtailstreamforms.git 76 | 77 | Run the docker container 78 | 79 | .. code:: bash 80 | 81 | $ cd wagtailstreamforms 82 | $ docker-compose up 83 | 84 | Create yourself a superuser 85 | 86 | .. code:: bash 87 | 88 | $ docker-compose exec app bash 89 | $ python manage.py createsuperuser 90 | 91 | Go to http://127.0.0.1:8000 92 | 93 | .. |tests| image:: https://github.com/labd/wagtailstreamforms/workflows/Python%20Tests/badge.svg 94 | :target: https://github.com/labd/wagtailstreamforms/actions?query=workflow%3A%22Python+Tests%22 95 | .. |Codecov| image:: https://codecov.io/gh/labd/wagtailstreamforms/branch/master/graph/badge.svg 96 | :target: https://codecov.io/gh/labd/wagtailstreamforms 97 | .. |pypi| image:: https://img.shields.io/pypi/v/wagtailstreamforms.svg 98 | :target: https://pypi.org/project/wagtailstreamforms/ 99 | .. |github| image:: https://img.shields.io/github/stars/labd/wagtailstreamforms.svg?style=social&logo=github 100 | :target: https://github.com/labd/wagtailstreamforms/stargazers 101 | 102 | -------------------------------------------------------------------------------- /tests/fields/test_hook_select_field.py: -------------------------------------------------------------------------------- 1 | from django.core import serializers 2 | from django.core.exceptions import ValidationError 3 | from django.forms import CheckboxSelectMultiple 4 | 5 | from wagtailstreamforms.fields import HookMultiSelectFormField, HookSelectField 6 | 7 | from ..models import HookSelectModel 8 | from ..test_case import AppTestCase 9 | 10 | 11 | class HookSelectFieldTests(AppTestCase): 12 | def test_default_choices_has_in_app_hooks(self): 13 | field = self.get_field(HookSelectModel, "hooks") 14 | self.assertEqual( 15 | field.get_choices_default(), 16 | [("save_form_submission_data", "Save form submission data")], 17 | ) 18 | 19 | def test_registering_hook_adds_that_as_a_choice(self): 20 | def before_hook(): 21 | pass 22 | 23 | with self.register_hook("process_form_submission", before_hook, order=-1): 24 | field = self.get_field(HookSelectModel, "hooks") 25 | self.assertEqual( 26 | field.get_choices_default(), 27 | [ 28 | ("before_hook", "Before hook"), 29 | ("save_form_submission_data", "Save form submission data"), 30 | ], 31 | ) 32 | 33 | def test_db_prep_save(self): 34 | field = HookSelectField("test") 35 | field.set_attributes_from_name("hooks") 36 | self.assertEqual(None, field.get_db_prep_save(None, connection=None)) 37 | self.assertEqual( 38 | "do_foo,do_bar", 39 | field.get_db_prep_save(["do_foo", "do_bar"], connection=None), 40 | ) 41 | 42 | def test_to_python(self): 43 | field = HookSelectField() 44 | self.assertEqual(field.to_python(None), []) 45 | self.assertEqual(field.to_python(""), []) 46 | self.assertEqual(field.to_python(["do_foo", "do_bar"]), ["do_foo", "do_bar"]) 47 | self.assertEqual(field.to_python("do_foo,do_bar"), ["do_foo", "do_bar"]) 48 | 49 | def test_formfield(self): 50 | field = self.get_field(HookSelectModel, "hooks") 51 | formfield = field.formfield() 52 | self.assertTrue(isinstance(formfield, HookMultiSelectFormField)) 53 | self.assertTrue(isinstance(formfield.widget, CheckboxSelectMultiple)) 54 | self.assertEqual( 55 | formfield.choices, 56 | [("save_form_submission_data", "Save form submission data")], 57 | ) 58 | 59 | def test_serialisation(self): 60 | hooks = ["do_foo", "do_bar"] 61 | obj = next( 62 | serializers.deserialize( 63 | "json", serializers.serialize("json", [HookSelectModel(hooks=hooks)]) 64 | ) 65 | ).object 66 | self.assertEqual(obj.hooks, hooks) 67 | 68 | def test_value(self): 69 | obj = HookSelectModel(hooks=["save_form_submission_data"]) 70 | self.assertEqual(obj.hooks, ["save_form_submission_data"]) 71 | 72 | def test_empty(self): 73 | obj = HookSelectModel(hooks="") 74 | self.assertEqual(obj.hooks, "") 75 | 76 | def test_empty_list(self): 77 | obj = HookSelectModel(hooks=[]) 78 | self.assertEqual(obj.hooks, []) 79 | 80 | def test_null(self): 81 | obj = HookSelectModel(hooks=None) 82 | self.assertEqual(obj.hooks, None) 83 | 84 | def test_validate(self): 85 | # invalid choice 86 | obj = HookSelectModel(hooks=["do_woop"]) 87 | self.assertRaises(ValidationError, obj.full_clean) 88 | 89 | # valid 90 | obj = HookSelectModel(hooks=["save_form_submission_data"]) 91 | obj.full_clean() 92 | 93 | def test_save(self): 94 | HookSelectModel.objects.create(id=10, hooks=["save_form_submission_data"]) 95 | obj = HookSelectModel.objects.get(id=10) 96 | self.assertEqual(obj.hooks, ["save_form_submission_data"]) 97 | 98 | def test_save_empty(self): 99 | HookSelectModel.objects.create(id=10, hooks="") 100 | obj = HookSelectModel.objects.get(id=10) 101 | self.assertEqual(obj.hooks, []) 102 | 103 | def test_save_empty_list(self): 104 | HookSelectModel.objects.create(id=10, hooks=[]) 105 | obj = HookSelectModel.objects.get(id=10) 106 | self.assertEqual(obj.hooks, []) 107 | 108 | def test_save_null(self): 109 | HookSelectModel.objects.create(id=10, hooks=None) 110 | obj = HookSelectModel.objects.get(id=10) 111 | self.assertEqual(obj.hooks, []) 112 | -------------------------------------------------------------------------------- /wagtailstreamforms/views/submission_list.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | 4 | from django.core.exceptions import PermissionDenied 5 | from django.http import Http404, HttpResponse 6 | from django.utils.encoding import smart_str 7 | from django.utils.translation import gettext as _ 8 | from django.views.generic import ListView 9 | from django.views.generic.detail import SingleObjectMixin 10 | from wagtail_modeladmin.helpers import PermissionHelper 11 | 12 | from wagtailstreamforms import hooks 13 | from wagtailstreamforms.forms import SelectDateForm 14 | from wagtailstreamforms.models import Form 15 | 16 | 17 | class SubmissionListView(SingleObjectMixin, ListView): 18 | paginate_by = 25 19 | page_kwarg = "p" 20 | template_name = "streamforms/index_submissions.html" 21 | filter_form = None 22 | model = Form 23 | 24 | @property 25 | def permission_helper(self): 26 | return PermissionHelper(model=self.model) 27 | 28 | def dispatch(self, request, *args, **kwargs): 29 | self.object = self.get_object() 30 | if not self.permission_helper.user_can_list(self.request.user): 31 | raise PermissionDenied 32 | return super().dispatch(request, *args, **kwargs) 33 | 34 | def get_object(self, queryset=None): 35 | pk = self.kwargs.get(self.pk_url_kwarg) 36 | try: 37 | qs = self.model.objects.all() 38 | for fn in hooks.get_hooks("construct_form_queryset"): 39 | qs = fn(qs, self.request) 40 | return qs.get(pk=pk) 41 | except self.model.DoesNotExist: 42 | raise Http404(_("No Form found matching the query")) 43 | 44 | def get(self, request, *args, **kwargs): 45 | self.filter_form = SelectDateForm(request.GET) 46 | 47 | if request.GET.get("action") == "CSV": 48 | return self.csv() 49 | 50 | return super().get(request, *args, **kwargs) 51 | 52 | def csv(self): 53 | queryset = self.get_queryset() 54 | data_fields = self.object.get_data_fields() 55 | data_headings = [smart_str(label) for name, label in data_fields] 56 | 57 | response = HttpResponse(content_type="text/csv; charset=utf-8") 58 | response["Content-Disposition"] = "attachment;filename=export.csv" 59 | 60 | writer = csv.writer(response) 61 | writer.writerow(data_headings) 62 | for s in queryset: 63 | data_row = [] 64 | form_data = s.get_data() 65 | for name, label in data_fields: 66 | data_row.append(smart_str(form_data.get(name))) 67 | writer.writerow(data_row) 68 | 69 | return response 70 | 71 | def get_queryset(self): 72 | submission_class = self.object.get_submission_class() 73 | self.queryset = submission_class._default_manager.filter(form=self.object) 74 | 75 | # filter the queryset by the required dates 76 | if self.filter_form.is_valid(): 77 | date_from = self.filter_form.cleaned_data.get("date_from") 78 | date_to = self.filter_form.cleaned_data.get("date_to") 79 | if date_from: 80 | self.queryset = self.queryset.filter(submit_time__gte=date_from) 81 | if date_to: 82 | date_to += datetime.timedelta(days=1) 83 | self.queryset = self.queryset.filter(submit_time__lte=date_to) 84 | 85 | return self.queryset.prefetch_related("files") 86 | 87 | def get_context_data(self, **kwargs): 88 | context = super().get_context_data(**kwargs) 89 | 90 | data_fields = self.object.get_data_fields() 91 | data_headings = [label for name, label in data_fields] 92 | 93 | # populate data rows from paginator 94 | data_rows = [] 95 | for s in context["page_obj"]: 96 | form_data = s.get_data() 97 | form_files = s.files.all() 98 | data_row = [form_data.get(name) for name, label in data_fields] 99 | data_rows.append({"model_id": s.id, "fields": data_row, "files": form_files}) 100 | 101 | context.update( 102 | { 103 | "filter_form": self.filter_form, 104 | "data_rows": data_rows, 105 | "data_headings": data_headings, 106 | "has_delete_permission": self.permission_helper.user_can_delete_obj( 107 | self.request.user, self.object 108 | ), 109 | } 110 | ) 111 | 112 | return context 113 | -------------------------------------------------------------------------------- /wagtailstreamforms/migrations/0003_alter_form_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.8 on 2023-04-14 05:21 2 | 3 | from django.db import migrations 4 | import wagtail.blocks 5 | import wagtailstreamforms.streamfield 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('wagtailstreamforms', '0002_form_site'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='form', 17 | name='fields', 18 | field=wagtailstreamforms.streamfield.FormFieldsStreamField([('singleline', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='placeholder', label='Text field (single line)')), ('multiline', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='placeholder', label='Text field (multi line)')), ('date', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='date', label='Date field')), ('datetime', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='time', label='Time field')), ('email', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='mail', label='Email field')), ('url', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='link', label='URL field')), ('number', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='placeholder', label='Number field')), ('dropdown', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('empty_label', wagtail.blocks.CharBlock(required=False)), ('choices', wagtail.blocks.ListBlock(wagtail.blocks.CharBlock(label='Option')))], icon='arrow-down-big', label='Dropdown field')), ('radio', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('choices', wagtail.blocks.ListBlock(wagtail.blocks.CharBlock(label='Option')))], icon='radio-empty', label='Radio buttons')), ('checkboxes', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('choices', wagtail.blocks.ListBlock(wagtail.blocks.CharBlock(label='Option')))], icon='tick-inverse', label='Checkboxes')), ('checkbox', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False))], icon='tick-inverse', label='Checkbox field')), ('hidden', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False)), ('default_value', wagtail.blocks.CharBlock(required=False))], icon='no-view', label='Hidden field')), ('singlefile', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False))], icon='doc-full-inverse', label='File field')), ('multifile', wagtail.blocks.StructBlock([('label', wagtail.blocks.CharBlock()), ('help_text', wagtail.blocks.CharBlock(required=False)), ('required', wagtail.blocks.BooleanBlock(required=False))], icon='doc-full-inverse', label='Files field'))], use_json_field=True, verbose_name='Fields'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/views/test_delete.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission, User 2 | from django.urls import reverse 3 | 4 | from wagtailstreamforms.models import Form, FormSubmission 5 | 6 | from ..test_case import AppTestCase 7 | 8 | 9 | class DeleteViewTestCase(AppTestCase): 10 | fixtures = ["test.json"] 11 | 12 | def setUp(self): 13 | User.objects.create_superuser("user", "user@test.com", "password") 14 | form = Form.objects.get(pk=1) 15 | s1 = FormSubmission.objects.create(form=form, form_data='{"foo":1}') 16 | s2 = FormSubmission.objects.create(form=form, form_data='{"foo":1}') 17 | FormSubmission.objects.create(form=form, form_data='{"foo":1}') 18 | 19 | delete_url = reverse( 20 | "wagtailstreamforms:streamforms_delete_submissions", kwargs={"pk": form.pk} 21 | ) 22 | 23 | self.invalid_delete_url = reverse( 24 | "wagtailstreamforms:streamforms_delete_submissions", kwargs={"pk": 100} 25 | ) 26 | self.single_url = "{}?selected-submissions={}".format(delete_url, s1.pk) 27 | self.multiple_url = "{}?selected-submissions={}&selected-submissions={}".format( 28 | delete_url, s1.pk, s2.pk 29 | ) 30 | self.redirect_url = reverse( 31 | "wagtailstreamforms:streamforms_submissions", kwargs={"pk": form.pk} 32 | ) 33 | 34 | self.client.login(username="user", password="password") 35 | 36 | def test_get_response(self): 37 | response = self.client.get(self.multiple_url) 38 | self.assertEqual(response.status_code, 200) 39 | 40 | def test_invalid_pk_raises_404(self): 41 | response = self.client.get(self.invalid_delete_url) 42 | self.assertEqual(response.status_code, 404) 43 | 44 | def test_get_context_has_submissions(self): 45 | response = self.client.get(self.multiple_url) 46 | self.assertEqual(response.context["submissions"].count(), 2) 47 | 48 | def test_get_response_confirm_text__plural(self): 49 | response = self.client.get(self.multiple_url) 50 | self.assertIn( 51 | "Are you sure you want to delete these form submissions?", 52 | str(response.content), 53 | ) 54 | 55 | def test_get_response_confirm_text__singular(self): 56 | response = self.client.get(self.single_url) 57 | self.assertIn( 58 | "Are you sure you want to delete this form submission?", 59 | str(response.content), 60 | ) 61 | 62 | def test_post_deletes(self): 63 | self.client.post(self.multiple_url) 64 | self.assertEqual(FormSubmission.objects.count(), 1) 65 | 66 | def test_post_redirects(self): 67 | response = self.client.post(self.multiple_url) 68 | self.assertRedirects(response, self.redirect_url) 69 | 70 | 71 | class DeleteViewPermissionTestCase(AppTestCase): 72 | fixtures = ["test.json"] 73 | 74 | def setUp(self): 75 | self.user = User.objects.create_user("user", "user@test.com", "password") 76 | 77 | self.form = Form.objects.get(pk=1) 78 | self.form_submission = FormSubmission.objects.create(form=self.form, form_data="{}") 79 | 80 | self.delete_url = "{}?selected-submissions={}".format( 81 | reverse( 82 | "wagtailstreamforms:streamforms_delete_submissions", 83 | kwargs={"pk": self.form.pk}, 84 | ), 85 | self.form_submission.pk, 86 | ) 87 | 88 | def test_no_user_no_access(self): 89 | response = self.client.get(self.delete_url) 90 | self.assertEqual(response.status_code, 302) 91 | self.assertTrue(response.url.startswith("/cms/login/?next=/cms/wagtailstreamforms")) 92 | 93 | def test_user_with_no_perm_no_access(self): 94 | access_admin = Permission.objects.get(codename="access_admin") 95 | self.user.user_permissions.add(access_admin) 96 | 97 | self.client.login(username="user", password="password") 98 | 99 | response = self.client.get(self.delete_url) 100 | self.assertEqual(response.status_code, 302) 101 | self.assertTrue(response.url.startswith("/cms/")) 102 | 103 | def test_user_with_delete_perm_has_access(self): 104 | access_admin = Permission.objects.get(codename="access_admin") 105 | form_perm = Permission.objects.get(codename="delete_form") 106 | self.user.user_permissions.add(access_admin, form_perm) 107 | 108 | self.client.login(username="user", password="password") 109 | 110 | response = self.client.get(self.delete_url) 111 | self.assertEqual(response.status_code, 200) 112 | -------------------------------------------------------------------------------- /wagtailstreamforms/templates/streamforms/index_submissions.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n %} 3 | {% block titletag %}{% blocktrans with form_title=object.title|capfirst %}Submissions of {{ form_title }}{% endblocktrans %}{% endblock %} 4 | {% block extra_js %} 5 | {{ block.super }} 6 | {% include "streamforms/wagtailadmin/shared/datetimepicker_translations.html" %} 7 | 8 | 66 | {% endblock %} 67 | {% block content %} 68 |
69 |
70 |
71 |
72 |
73 |

74 | {% blocktrans with form_title=object.title|capfirst %}Form data {{ form_title }}{% endblocktrans %} 75 |

76 |
77 | 87 |
88 |
89 | 90 |
91 |
92 |
93 |
94 |
95 | {% if object_list %} 96 |
97 | {% include "streamforms/list_submissions.html" %} 98 | {% include "streamforms/partials/pagination_nav.html" with items=page_obj %} 99 |
100 | {% else %} 101 |

{% blocktrans with title=object.title %}There have been no submissions of the '{{ title }}' form.{% endblocktrans %}

102 | {% endif %} 103 |
104 | {% endblock %} 105 | -------------------------------------------------------------------------------- /tests/views/test_submission_list.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.contrib.auth.models import Permission, User 4 | from django.urls import reverse 5 | 6 | from wagtailstreamforms.models import Form, FormSubmission 7 | 8 | from ..test_case import AppTestCase 9 | 10 | 11 | class SubmissionListViewTestCase(AppTestCase): 12 | fixtures = ["test.json"] 13 | 14 | def setUp(self): 15 | User.objects.create_superuser("user", "user@test.com", "password") 16 | form = Form.objects.get(pk=1) 17 | s1 = FormSubmission.objects.create(form=form, form_data='{"foo":1}') 18 | s1.submit_time = datetime(2017, 1, 1, 0, 0, 0, 0) 19 | s1.save() 20 | s2 = FormSubmission.objects.create(form=form, form_data='{"foo":1}') 21 | s2.submit_time = datetime(2017, 1, 2, 10, 0, 0, 0) 22 | s2.save() 23 | FormSubmission.objects.create(form=form, form_data='{"foo":1}') 24 | 25 | self.list_url = reverse( 26 | "wagtailstreamforms:streamforms_submissions", kwargs={"pk": form.pk} 27 | ) 28 | self.invalid_list_url = reverse( 29 | "wagtailstreamforms:streamforms_submissions", kwargs={"pk": 100} 30 | ) 31 | self.filter_url = "{}?date_from=2017-01-01&date_to=2017-01-02&action=filter".format( 32 | self.list_url 33 | ) 34 | self.invalid_filter_url = "{}?date_from=xx&date_to=xx&action=filter".format(self.list_url) 35 | self.csv_url = "{}?date_from=2017-01-01&date_to=2017-01-02&action=CSV".format( 36 | self.list_url 37 | ) 38 | 39 | self.client.login(username="user", password="password") 40 | 41 | def test_get_responds(self): 42 | response = self.client.get(self.list_url) 43 | self.assertEqual(response.status_code, 200) 44 | 45 | def test_invalid_pk_raises_404(self): 46 | response = self.client.get(self.invalid_list_url) 47 | self.assertEqual(response.status_code, 404) 48 | 49 | def test_get_context(self): 50 | response = self.client.get(self.list_url) 51 | self.assertIn("filter_form", response.context) 52 | self.assertIn("data_rows", response.context) 53 | self.assertIn("data_headings", response.context) 54 | self.assertEqual(len(response.context["data_rows"]), 3) 55 | 56 | def test_get_filtering(self): 57 | response = self.client.get(self.filter_url) 58 | self.assertEqual(len(response.context["data_rows"]), 2) 59 | 60 | def test_get_filtering_doesnt_happen_with_invalid_form(self): 61 | response = self.client.get(self.invalid_filter_url) 62 | self.assertEqual(len(response.context["data_rows"]), 3) 63 | 64 | def test_get_csv(self): 65 | response = self.client.get(self.csv_url) 66 | self.assertEqual(response.get("Content-Disposition"), "attachment;filename=export.csv") 67 | 68 | 69 | class ListViewPermissionTestCase(AppTestCase): 70 | fixtures = ["test.json"] 71 | 72 | def setUp(self): 73 | self.user = User.objects.create_user("user", "user@test.com", "password") 74 | self.form = Form.objects.get(pk=1) 75 | self.list_url = reverse( 76 | "wagtailstreamforms:streamforms_submissions", kwargs={"pk": self.form.pk} 77 | ) 78 | 79 | def test_no_user_no_access(self): 80 | response = self.client.get(self.list_url) 81 | self.assertEqual(response.status_code, 302) 82 | self.assertTrue(response.url.startswith("/cms/login/?next=/cms/wagtailstreamforms")) 83 | 84 | def test_user_with_no_perm_no_access(self): 85 | access_admin = Permission.objects.get(codename="access_admin") 86 | self.user.user_permissions.add(access_admin) 87 | 88 | self.client.login(username="user", password="password") 89 | 90 | response = self.client.get(self.list_url) 91 | self.assertEqual(response.status_code, 302) 92 | self.assertTrue(response.url.startswith("/cms/")) 93 | 94 | def test_user_with_add_perm_has_access(self): 95 | access_admin = Permission.objects.get(codename="access_admin") 96 | form_perm = Permission.objects.get(codename="add_form") 97 | self.user.user_permissions.add(access_admin, form_perm) 98 | self.user.is_staff = True 99 | self.user.save() 100 | 101 | self.client.login(username="user", password="password") 102 | 103 | response = self.client.get(self.list_url) 104 | self.assertEqual(response.status_code, 200) 105 | 106 | def test_user_with_change_perm_has_access(self): 107 | access_admin = Permission.objects.get(codename="access_admin") 108 | form_perm = Permission.objects.get(codename="change_form") 109 | self.user.user_permissions.add(access_admin, form_perm) 110 | self.user.is_staff = True 111 | self.user.save() 112 | 113 | self.client.login(username="user", password="password") 114 | 115 | response = self.client.get(self.list_url) 116 | self.assertEqual(response.status_code, 200) 117 | 118 | def test_user_with_delete_perm_has_access(self): 119 | access_admin = Permission.objects.get(codename="access_admin") 120 | form_perm = Permission.objects.get(codename="delete_form") 121 | self.user.user_permissions.add(access_admin, form_perm) 122 | self.user.is_staff = True 123 | self.user.save() 124 | 125 | self.client.login(username="user", password="password") 126 | 127 | response = self.client.get(self.list_url) 128 | self.assertEqual(response.status_code, 200) 129 | -------------------------------------------------------------------------------- /tests/blocks/test_form_block.py: -------------------------------------------------------------------------------- 1 | from wagtailstreamforms.blocks import WagtailFormBlock 2 | from wagtailstreamforms.models import Form 3 | 4 | from ..test_case import AppTestCase 5 | 6 | 7 | class TestFormBlockTestCase(AppTestCase): 8 | fixtures = ["test.json"] 9 | 10 | def setUp(self): 11 | self.form = Form.objects.get(pk=1) 12 | 13 | def test_render(self): 14 | block = WagtailFormBlock() 15 | 16 | html = block.render( 17 | block.to_python( 18 | {"form": self.form.pk, "form_action": ".", "form_reference": "some-ref"} 19 | ) 20 | ) 21 | 22 | # Test critical elements that should be present 23 | self.assertIn("

Basic Form

", html) 24 | self.assertIn('action="."', html) 25 | self.assertIn('method="post"', html) 26 | self.assertIn('enctype="multipart/form-data"', html) 27 | 28 | # Check hidden fields 29 | self.assertIn(f'singleline', html) 45 | self.assertIn('', html) 46 | 47 | # Check help text 48 | self.assertIn('

Help

', html) 49 | 50 | # Check dropdown options 51 | self.assertIn('', html) 52 | self.assertIn('', html) 53 | self.assertIn('', html) 54 | 55 | # Check submit button 56 | self.assertIn('', html) 57 | 58 | def test_render_when_form_deleted(self): 59 | block = WagtailFormBlock() 60 | 61 | html = block.render( 62 | block.to_python({"form": 100, "form_action": "/foo/", "form_reference": "some-ref"}) 63 | ) 64 | 65 | self.assertIn("

Sorry, this form has been deleted.

", html) 66 | 67 | def test_clean_adds_form_reference(self): 68 | block = WagtailFormBlock() 69 | 70 | value = block.clean({"form": self.form.pk, "form_action": "/foo/"}) 71 | 72 | self.assertIsNotNone(value.get("form_reference")) 73 | 74 | def test_clean_keeps_existing_form_reference(self): 75 | block = WagtailFormBlock() 76 | 77 | value = block.clean( 78 | {"form": self.form.pk, "form_action": "/foo/", "form_reference": "some-ref"} 79 | ) 80 | 81 | self.assertEqual(value.get("form_reference"), "some-ref") 82 | 83 | def test_context_has_form(self): 84 | block = WagtailFormBlock() 85 | 86 | context = block.get_context( 87 | block.to_python( 88 | { 89 | "form": self.form.pk, 90 | "form_action": "/foo/", 91 | "form_reference": "some-ref", 92 | } 93 | ) 94 | ) 95 | 96 | self.assertIsNotNone(context["form"]) 97 | 98 | def test_context_form_is_invalid_when_parent_context_has_this_form_with_errors( 99 | self, 100 | ): 101 | invalid_form = self.form.get_form({"form_id": self.form.id, "form_reference": "some-ref"}) 102 | assert not invalid_form.is_valid() 103 | 104 | self.assertDictEqual( 105 | invalid_form.errors, 106 | { 107 | "singleline": ["This field is required."], 108 | "multiline": ["This field is required."], 109 | "date": ["This field is required."], 110 | "datetime": ["This field is required."], 111 | "email": ["This field is required."], 112 | "url": ["This field is required."], 113 | "number": ["This field is required."], 114 | "dropdown": ["This field is required."], 115 | "radio": ["This field is required."], 116 | "checkboxes": ["This field is required."], 117 | "checkbox": ["This field is required."], 118 | "hidden": ["This field is required."], 119 | "singlefile": ["This field is required."], 120 | "multifile": ["This field is required."], 121 | }, 122 | ) 123 | 124 | # this is the context a page will set for an invalid form 125 | parent_context = { 126 | "invalid_stream_form_reference": "some-ref", 127 | "invalid_stream_form": invalid_form, 128 | } 129 | 130 | block = WagtailFormBlock() 131 | 132 | # get a new block context 133 | context = block.get_context( 134 | block.to_python( 135 | { 136 | "form": self.form.pk, 137 | "form_action": "/foo/", 138 | "form_reference": "some-ref", 139 | } 140 | ), 141 | parent_context, 142 | ) 143 | 144 | # finally make sure the form in the block is the one with errors 145 | self.assertEqual(context["form"], invalid_form) 146 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Wagtail Streamforms documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Oct 14 14:40:45 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 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 | # 20 | import django 21 | import os 22 | import sys 23 | import wagtailstreamforms 24 | 25 | sys.path.insert(0, os.path.abspath(".")) 26 | sys.path.insert(0, os.path.abspath("..")) 27 | 28 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 29 | django.setup() 30 | 31 | 32 | # -- General configuration ------------------------------------------------ 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = ["sphinx.ext.autodoc"] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = ".rst" 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | project = "Wagtail Streamforms" 57 | copyright = "2018, Accent Design Group LTD" 58 | author = "Stuart George" 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = wagtailstreamforms.__version__ 66 | # The full version, including alpha/beta/rc tags. 67 | release = wagtailstreamforms.__version__ 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = "default" 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = "karma_sphinx_theme" 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ["_static"] 105 | 106 | 107 | # -- Options for HTMLHelp output ------------------------------------------ 108 | 109 | # Output file base name for HTML help builder. 110 | htmlhelp_basename = "WagtailStreamformsdoc" 111 | 112 | 113 | # -- Options for LaTeX output --------------------------------------------- 114 | 115 | latex_elements = { 116 | # The paper size ('letterpaper' or 'a4paper'). 117 | # 118 | # 'papersize': 'letterpaper', 119 | # The font size ('10pt', '11pt' or '12pt'). 120 | # 121 | # 'pointsize': '10pt', 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | ( 135 | master_doc, 136 | "WagtailStreamforms.tex", 137 | "Wagtail Streamforms Documentation", 138 | "STuart George", 139 | "manual", 140 | ), 141 | ] 142 | 143 | 144 | # -- Options for manual page output --------------------------------------- 145 | 146 | # One entry per manual page. List of tuples 147 | # (source start file, name, description, authors, manual section). 148 | man_pages = [(master_doc, "wagtailstreamforms", "Wagtail Streamforms Documentation", [author], 1)] 149 | 150 | 151 | # -- Options for Texinfo output ------------------------------------------- 152 | 153 | # Grouping the document tree into Texinfo files. List of tuples 154 | # (source start file, target name, title, author, 155 | # dir menu entry, description, category) 156 | texinfo_documents = [ 157 | ( 158 | master_doc, 159 | "WagtailStreamforms", 160 | "Wagtail Streamforms Documentation", 161 | author, 162 | "WagtailStreamforms", 163 | "One line description of project.", 164 | "Miscellaneous", 165 | ), 166 | ] 167 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | Changelog 3 | ********* 4 | 5 | 4.0.3 6 | ----- 7 | * no BigAutoField in tests 8 | * add missing migrations; fail when migrations are incomplete 9 | 10 | 4.0.2 11 | ----- 12 | * pass use_json_field to StreamField initializer 13 | 14 | 4.0.1 15 | ----- 16 | * cleanup non-pep version hack 17 | * cleanup test matrix in workflow 18 | * only support wagtail>=4.1 19 | 20 | 3.22 21 | ---- 22 | * Add Wagtail 4.2 support 23 | * Add Django 4.1 support 24 | * Fix issue with FormChooserBlock 25 | 26 | 3.21.0 27 | ------ 28 | * Add Wagtail 4.1 support 29 | * Update Wagtail menu icon 30 | * Add nightly test against Wagtail main branch 31 | * feat: show the form reference field in the list view and export 32 | * Update Dutch translations 33 | 34 | 3.20.1 35 | ------ 36 | * Remove CircleCI from package 37 | 38 | 3.20.0 39 | ------ 40 | * Wagtail 3.0 and 4.0 support 41 | 42 | 3.19.1 43 | ------ 44 | * Update get_options method for CheckboxesField, RadioField, MultiSelectField and DropdownField 45 | 46 | 3.19.0 47 | ------ 48 | * Django 4.0 support 49 | 50 | 3.18.1 51 | ------ 52 | * Dropped Django 2.X 3.0 and 3.1 support 53 | * Dropped Python 3.6 support 54 | * Dropped Wagtail 2.2 > 2.14 support 55 | 56 | 3.18.0 57 | ------ 58 | * Wagtail 2.16 support 59 | 60 | 3.17.0 61 | ------ 62 | * Wagtail 2.15 support 63 | 64 | 3.16.3 65 | ------ 66 | * Republish do to pypi issue 67 | 68 | 3.16.2 69 | ------ 70 | * Republish do to formatting issue 71 | 72 | 3.16.1 73 | ------ 74 | * Apply black and isort 75 | 76 | 3.16.0 77 | ------ 78 | * Wagtail 2.14 support 79 | 80 | 3.15.0 81 | ------ 82 | * Wagtail 2.13 support 83 | 84 | 3.14.0 85 | ------ 86 | * Wagtail 2.12 support 87 | 88 | 3.13.0 89 | ------ 90 | * Wagtail 2.11 support 91 | * Dropped Django 2.0 and 2.1 support 92 | 93 | 3.12.0 94 | ------ 95 | * Allow fields classes to more easily control the name, label and other values used for form fields 96 | 97 | 3.11.0 98 | ------ 99 | * Django 3.0 support 100 | * Wagtail 2.10 support 101 | 102 | 3.10.0 103 | ------ 104 | * Wagtail 2.9 support 105 | 106 | 3.9.0 107 | ----- 108 | * Removed 'multiselect' form field 109 | * Wagtail 2.8 support 110 | * Dropped Wagtail 2.0 and 2.1 support 111 | * Integrated with GitHub actions 112 | 113 | 3.8.0 114 | ----- 115 | * Wagtail 2.7 Support 116 | 117 | 3.7.0 118 | ----- 119 | * Wagtail 2.6 Support 120 | 121 | 3.6.1 122 | ----- 123 | * Republish do to pypi issue 124 | 125 | 3.6.0 126 | ----- 127 | * Wagtail 2.5 Support 128 | 129 | 3.5.0 130 | ----- 131 | * Wagtail 2.4 Support 132 | * Tweak docs to ensure files work in js example (Thanks Aimee Hendrycks) 133 | 134 | 3.4.0 135 | ----- 136 | * Support for Wagtail 2.3 137 | 138 | 3.3.0 139 | ----- 140 | * fix issue with saving a submission with a file attached on disk. 141 | * added new setting ``WAGTAILSTREAMFORM_ENABLE_BUILTIN_HOOKS`` default ``True`` to allow the inbuilt form processing hooks to be disabled. 142 | 143 | 3.2.0 144 | ----- 145 | * fix template that inherited from wagtailforms to wagtailadmin 146 | 147 | 3.1.0 148 | ----- 149 | * Support for Wagtail 2.2 150 | 151 | 3.0.0 152 | ----- 153 | Version 3 is a major re-write and direction change and therefor any version prior 154 | to this needs to be removed in its entirety first. 155 | 156 | Whats New: 157 | 158 | * Update to Wagtail 2.1 159 | * The concept of creating a custom form class to add functionality has been removed. 160 | * Along with the concept of custom form submission classes. 161 | * Fields are now added via a StreamField and you can define your own like ReCAPTCHA or RegexFields. 162 | * You can easily overwrite fields to add things like widget attributes. 163 | * You can define a model that will allow you to save additional settings for each form. 164 | * The form submission is processed via hooks instead of baked into the models. 165 | * You can create as many form submission hooks as you like to process, email etc the data as you wish. These will be available to all forms that you can enable/disable at will. 166 | * Files can now be uploaded and are stored along with the submission using the default storage. 167 | * There is a management command to easily remove old submission data. 168 | 169 | 2.1.2 170 | ----- 171 | * Added wagtail framework classifier 172 | 173 | 2.1.1 174 | ----- 175 | * Fixed another migration issue 176 | 177 | 2.1.0 178 | ----- 179 | * Update to Wagtail 2.1 180 | 181 | 2.0.1 182 | ----- 183 | * Fixed migration issue #70 184 | 185 | 2.0.0 186 | ----- 187 | * Added support for wagtail 2. 188 | 189 | 1.6.3 190 | ----- 191 | * Fix issue where js was not in final package 192 | 193 | 1.6.2 194 | ----- 195 | * Added javascript to auto populate the form slug from the name 196 | 197 | 1.6.1 198 | ----- 199 | * Small tidy up in form code 200 | 201 | 1.6.0 202 | ----- 203 | * Stable Release 204 | 205 | 1.5.2 206 | ----- 207 | * Added ``AbstractEmailForm`` to more easily allow creating additional form types. 208 | 209 | 1.5.1 210 | ----- 211 | * Fix migrations being regenerated when template choices change 212 | 213 | 1.5.0 214 | ----- 215 | * Removed all project dependencies except wagtail and recapcha 216 | * The urls no longer need to be specified in your ``urls.py`` and can be removed. 217 | 218 | 1.4.4 219 | ----- 220 | * The template tag now has the full page context incase u need a reference to the user or page 221 | 222 | 1.4.3 223 | ----- 224 | * Fixed bug where messages are not available in the template tags context 225 | 226 | 1.4.2 227 | ----- 228 | * Removed label value from recapcha field 229 | * Added setting to set order of menu item in cms admin 230 | 231 | 1.4.1 232 | ----- 233 | * Added an optional error message to display if the forms have errors 234 | 235 | 1.4.0 236 | ----- 237 | * Added a template tag that can be used to render a form. Incase you want it to appear outside a streamfield 238 | 239 | 1.3.0 240 | ----- 241 | * A form and it's fields can easily be copied to a new form from within the admin area 242 | 243 | 1.2.3 244 | ----- 245 | * Fix paginator on submission list not remembering date filters 246 | 247 | 1.2.2 248 | ----- 249 | * Form submission viewing and deleting permissions have been implemented 250 | 251 | 1.2.1 252 | ----- 253 | * On the event that a form is deleted that is still referenced in a streamfield, we are rendering a generic template that can be overridden to warn the end user 254 | 255 | 1.2.0 256 | ----- 257 | * In the form builder you can now specify a page to redirect to upon successful submission of the form 258 | * The page mixin StreamFormPageMixin that needed to be included in every page has now been replaced by a wagtail before_serve_page hook so you will need to remove this mixin 259 | 260 | 1.1.1 261 | ----- 262 | * Fixed bug where multiple forms of same type in a streamfield were both showing validation errors when one submitted 263 | -------------------------------------------------------------------------------- /wagtailstreamforms/models/form.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | from wagtail import VERSION as WAGTAIL_VERSION 7 | from wagtail.admin.panels import ( 8 | FieldPanel, 9 | MultiFieldPanel, 10 | ObjectList, 11 | PageChooserPanel, 12 | TabbedInterface, 13 | ) 14 | from wagtail.models import Site 15 | 16 | from wagtailstreamforms import hooks 17 | from wagtailstreamforms.conf import get_setting 18 | from wagtailstreamforms.fields import HookSelectField 19 | from wagtailstreamforms.forms import FormBuilder 20 | from wagtailstreamforms.streamfield import FormFieldsStreamField 21 | from wagtailstreamforms.utils.general import get_slug_from_string 22 | from wagtailstreamforms.utils.loading import get_advanced_settings_model 23 | 24 | from .submission import FormSubmission 25 | 26 | 27 | class FormQuerySet(models.QuerySet): 28 | def for_site(self, site): 29 | """Return all forms for a specific site.""" 30 | return self.filter(site=site) 31 | 32 | 33 | class AbstractForm(models.Model): 34 | site = models.ForeignKey(Site, on_delete=models.SET_NULL, null=True, blank=True) 35 | title = models.CharField(_("Title"), max_length=255) 36 | slug = models.SlugField( 37 | _("Slug"), 38 | allow_unicode=True, 39 | max_length=255, 40 | unique=True, 41 | help_text=_("Used to identify the form in template tags"), 42 | ) 43 | template_name = models.CharField( 44 | _("Template"), max_length=255, choices=get_setting("FORM_TEMPLATES") 45 | ) 46 | fields = FormFieldsStreamField([], use_json_field=True, verbose_name=_("Fields")) 47 | submit_button_text = models.CharField( 48 | _("Submit button text"), max_length=100, default="Submit" 49 | ) 50 | success_message = models.CharField( 51 | _("Success message"), 52 | blank=True, 53 | max_length=255, 54 | help_text=_( 55 | "An optional success message to show when the form has been successfully submitted" 56 | ), 57 | ) 58 | error_message = models.CharField( 59 | _("Error message"), 60 | blank=True, 61 | max_length=255, 62 | help_text=_("An optional error message to show when the form has validation errors"), 63 | ) 64 | post_redirect_page = models.ForeignKey( 65 | "wagtailcore.Page", 66 | verbose_name=_("Post redirect page"), 67 | on_delete=models.SET_NULL, 68 | null=True, 69 | blank=True, 70 | related_name="+", 71 | help_text=_("The page to redirect to after a successful submission"), 72 | ) 73 | process_form_submission_hooks = HookSelectField(verbose_name=_("Submission hooks"), blank=True) 74 | 75 | objects = FormQuerySet.as_manager() 76 | 77 | settings_panels = [ 78 | FieldPanel("title", classname="full"), 79 | FieldPanel("slug"), 80 | FieldPanel("template_name"), 81 | FieldPanel("submit_button_text"), 82 | MultiFieldPanel( 83 | [FieldPanel("success_message"), FieldPanel("error_message")], _("Messages") 84 | ), 85 | FieldPanel("process_form_submission_hooks", classname="choice_field"), 86 | PageChooserPanel("post_redirect_page"), 87 | ] 88 | 89 | field_panels = [FieldPanel("fields")] 90 | 91 | edit_handler = TabbedInterface( 92 | [ 93 | ObjectList(settings_panels, heading=_("General")), 94 | ObjectList(field_panels, heading=_("Fields")), 95 | ] 96 | ) 97 | 98 | def __str__(self): 99 | return self.title 100 | 101 | class Meta: 102 | abstract = True 103 | ordering = ["title"] 104 | verbose_name = _("Form") 105 | verbose_name_plural = _("Forms") 106 | 107 | def copy(self): 108 | """Copy this form and its fields.""" 109 | 110 | form_copy = Form( 111 | site=self.site, 112 | title=self.title, 113 | slug=uuid.uuid4(), 114 | template_name=self.template_name, 115 | fields=self.fields, 116 | submit_button_text=self.submit_button_text, 117 | success_message=self.success_message, 118 | error_message=self.error_message, 119 | post_redirect_page=self.post_redirect_page, 120 | process_form_submission_hooks=self.process_form_submission_hooks, 121 | ) 122 | form_copy.save() 123 | 124 | # additionally copy the advanced settings if they exist 125 | SettingsModel = get_advanced_settings_model() 126 | 127 | if SettingsModel: 128 | try: 129 | advanced = SettingsModel.objects.get(form=self) 130 | advanced.pk = None 131 | advanced.form = form_copy 132 | advanced.save() 133 | except SettingsModel.DoesNotExist: 134 | pass 135 | 136 | return form_copy 137 | 138 | copy.alters_data = True 139 | 140 | def get_data_fields(self): 141 | """Returns a list of tuples with (field_name, field_label).""" 142 | 143 | data_fields = [("submit_time", _("Submission date"))] 144 | data_fields += [ 145 | (get_slug_from_string(field["value"]["label"]), field["value"]["label"]) 146 | for field in self.get_form_fields() 147 | ] 148 | if getattr(settings, "WAGTAILSTREAMFORMS_SHOW_FORM_REFERENCE", False): 149 | data_fields += [("form_reference", _("Form reference"))] 150 | return data_fields 151 | 152 | def get_form(self, *args, **kwargs): 153 | """Returns the form.""" 154 | 155 | form_class = self.get_form_class() 156 | return form_class(*args, **kwargs) 157 | 158 | def get_form_class(self): 159 | """Returns the form class.""" 160 | 161 | return FormBuilder(self.get_form_fields()).get_form_class() 162 | 163 | def get_form_fields(self): 164 | """Returns the form field's stream data.""" 165 | 166 | if WAGTAIL_VERSION >= (2, 12): 167 | form_fields = self.fields.raw_data 168 | else: 169 | form_fields = self.fields.stream_data 170 | for fn in hooks.get_hooks("construct_submission_form_fields"): 171 | form_fields = fn(form_fields) 172 | return form_fields 173 | 174 | def get_submission_class(self): 175 | """Returns submission class.""" 176 | 177 | return FormSubmission 178 | 179 | def process_form_submission(self, form): 180 | """Runs each hook if selected in the form.""" 181 | 182 | for fn in hooks.get_hooks("process_form_submission"): 183 | if fn.__name__ in self.process_form_submission_hooks: 184 | fn(self, form) 185 | 186 | 187 | class Form(AbstractForm): 188 | pass 189 | --------------------------------------------------------------------------------