浏览代码

New stream forms (#171)

Fixes #95
Cory Sutyak 5 年之前
父节点
当前提交
dac220550e
共有 29 个文件被更改,包括 2409 次插入178 次删除
  1. 25 1
      coderedcms/blocks/__init__.py
  2. 132 0
      coderedcms/blocks/stream_form_blocks.py
  3. 2 0
      coderedcms/forms.py
  4. 54 0
      coderedcms/migrations/0015_coderedsessionformsubmission_coderedsubmissionrevision.py
  5. 348 136
      coderedcms/models/page_models.py
  6. 34 18
      coderedcms/models/tests/test_page_models.py
  7. 8 0
      coderedcms/static/coderedcms/css/codered-editor.css
  8. 8 0
      coderedcms/static/coderedcms/js/codered-front.js
  9. 47 0
      coderedcms/static/coderedcms/js/codered-streamforms.js
  10. 1 1
      coderedcms/templates/coderedcms/blocks/quote_block.html
  11. 9 0
      coderedcms/templates/coderedcms/includes/stream_forms/render_field.html
  12. 62 0
      coderedcms/templates/coderedcms/pages/form_page.html
  13. 4 2
      coderedcms/templatetags/coderedcms_tags.py
  14. 30 0
      coderedcms/tests/testapp/migrations/0004_streamformconfirmemail_streamformpage.py
  15. 13 3
      coderedcms/tests/testapp/models.py
  16. 3 2
      coderedcms/utils.py
  17. 29 0
      coderedcms/wagtail_flexible_forms/LICENSE
  18. 269 0
      coderedcms/wagtail_flexible_forms/blocks.py
  19. 30 0
      coderedcms/wagtail_flexible_forms/edit_handlers.py
  20. 841 0
      coderedcms/wagtail_flexible_forms/models.py
  21. 320 0
      coderedcms/wagtail_flexible_forms/wagtail_hooks.py
  22. 47 4
      coderedcms/wagtail_hooks.py
  23. 1 3
      docs/_static/docs.css
  24. 1 1
      docs/conf.py
  25. 2 2
      docs/features/page_types/event_pages.rst
  26. 4 4
      docs/features/page_types/form_pages.rst
  27. 1 0
      docs/features/page_types/index.rst
  28. 83 0
      docs/features/page_types/stream_forms.rst
  29. 1 1
      docs/features/snippets/index.rst

+ 25 - 1
coderedcms/blocks/__init__.py

@@ -5,7 +5,11 @@ single `blocks` module.
 """
 """
 
 
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
+from wagtail.core.blocks import CharBlock, StreamBlock, StructBlock
 
 
+from coderedcms.wagtail_flexible_forms.blocks import FormStepBlock, FormStepsBlock
+
+from .stream_form_blocks import * #noqa
 from .base_blocks import * #noqa
 from .base_blocks import * #noqa
 from .html_blocks import * #noqa
 from .html_blocks import * #noqa
 from .metadata_blocks import * #noqa
 from .metadata_blocks import * #noqa
@@ -13,6 +17,7 @@ from .content_blocks import * #noqa
 from .layout_blocks import * #noqa
 from .layout_blocks import * #noqa
 
 
 
 
+
 # Collections of blocks commonly used together.
 # Collections of blocks commonly used together.
 
 
 HTML_STREAMBLOCKS = [
 HTML_STREAMBLOCKS = [
@@ -20,7 +25,7 @@ HTML_STREAMBLOCKS = [
     ('button', ButtonBlock()),
     ('button', ButtonBlock()),
     ('image', ImageBlock()),
     ('image', ImageBlock()),
     ('image_link', ImageLinkBlock()),
     ('image_link', ImageLinkBlock()),
-    ('html', blocks.RawHTMLBlock(icon='code', classname='monospace', label=_('HTML'))),
+    ('html', blocks.RawHTMLBlock(icon='code', classname='monospace', label=_('HTML'), )),
     ('download', DownloadBlock()),
     ('download', DownloadBlock()),
     ('embed_video', EmbedVideoBlock()),
     ('embed_video', EmbedVideoBlock()),
     ('quote', QuoteBlock()),
     ('quote', QuoteBlock()),
@@ -64,3 +69,22 @@ LAYOUT_STREAMBLOCKS = [
     ),
     ),
     ('html', blocks.RawHTMLBlock(icon='code', classname='monospace', label=_('HTML'))),
     ('html', blocks.RawHTMLBlock(icon='code', classname='monospace', label=_('HTML'))),
 ]
 ]
+
+STREAMFORM_FIELDBLOCKS = [
+    ('sf_singleline', CoderedStreamFormCharFieldBlock(group=_('Fields'))),
+    ('sf_multiline', CoderedStreamFormTextFieldBlock(group=_('Fields'))),
+    ('sf_number', CoderedStreamFormNumberFieldBlock(group=_('Fields'))),
+    ('sf_checkboxes', CoderedStreamFormCheckboxesFieldBlock(group=_('Fields'))),
+    ('sf_radios', CoderedStreamFormRadioButtonsFieldBlock(group=_('Fields'))),
+    ('sf_dropdown', CoderedStreamFormDropdownFieldBlock(group=_('Fields'))),
+    ('sf_checkbox', CoderedStreamFormCheckboxFieldBlock(group=_('Fields'))),
+    ('sf_date', CoderedStreamFormDateFieldBlock(group=_('Fields'))),
+    ('sf_time', CoderedStreamFormTimeFieldBlock(group=_('Fields'))),
+    ('sf_datetime', CoderedStreamFormDateTimeFieldBlock(group=_('Fields'))),
+    ('sf_image', CoderedStreamFormImageFieldBlock(group=_('Fields'))),
+    ('sf_file', CoderedStreamFormFileFieldBlock(group=_('Fields'))),
+]
+
+STREAMFORM_BLOCKS = [
+    ('step', CoderedStreamFormStepBlock(STREAMFORM_FIELDBLOCKS + HTML_STREAMBLOCKS)),
+]

+ 132 - 0
coderedcms/blocks/stream_form_blocks.py

@@ -0,0 +1,132 @@
+from django.utils.translation import ugettext_lazy as _
+from wagtail.core import blocks
+
+from coderedcms.wagtail_flexible_forms import blocks as form_blocks
+from coderedcms.blocks.base_blocks import BaseBlock, CoderedAdvSettings
+from coderedcms.forms import (
+    CoderedDateField, CoderedDateInput, 
+    CoderedDateTimeField, CoderedDateTimeInput, 
+    CoderedTimeField, CoderedTimeInput, 
+    SecureFileField
+)
+
+
+class CoderedFormAdvSettings(CoderedAdvSettings):
+
+    condition_trigger_id = blocks.CharBlock(
+        required=False,
+        max_length=255,
+        label=_('Condition Trigger ID'),
+        help_text=_('The "Custom ID" of another field that that will trigger this field to be shown/hidden.')
+    )
+    condition_trigger_value = blocks.CharBlock(
+        required=False,
+        max_length=255,
+        label=_('Condition Trigger Value'),
+        help_text=_('The value of the field in "Condition Trigger ID" that will trigger this field to be shown.')
+    )
+
+
+class FormBlockMixin(BaseBlock):
+    class Meta:
+        abstract=True
+
+    advsettings_class = CoderedFormAdvSettings
+
+
+class CoderedStreamFormFieldBlock(form_blocks.OptionalFormFieldBlock, FormBlockMixin):
+    pass
+
+
+class CoderedStreamFormCharFieldBlock(form_blocks.CharFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Text or Email input")
+        icon = "fa-window-minimize"
+
+
+class CoderedStreamFormTextFieldBlock(form_blocks.TextFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Multi-line text")
+        icon = "fa-align-left"
+
+
+class CoderedStreamFormNumberFieldBlock(form_blocks.NumberFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Numbers only")
+        icon = "fa-hashtag"
+
+
+class CoderedStreamFormCheckboxFieldBlock(form_blocks.CheckboxFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Single Checkbox")
+        icon = "fa-check-square-o"
+
+
+class CoderedStreamFormRadioButtonsFieldBlock(form_blocks.RadioButtonsFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Radios")
+        icon = "fa-list-ul"
+
+
+class CoderedStreamFormDropdownFieldBlock(form_blocks.DropdownFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Dropdown")
+        icon = "fa-list-alt"
+
+
+class CoderedStreamFormCheckboxesFieldBlock(form_blocks.CheckboxesFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Checkboxes")
+        icon = "fa-list-ul"
+
+
+class CoderedStreamFormDateFieldBlock(form_blocks.DateFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Date")
+        icon = "fa-calendar"
+    
+    field_class = CoderedDateField
+    widget = CoderedDateInput
+
+
+class CoderedStreamFormTimeFieldBlock(form_blocks.TimeFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Time")
+        icon = "fa-clock-o"
+
+    field_class = CoderedTimeField
+    widget = CoderedTimeInput
+
+
+class CoderedStreamFormDateTimeFieldBlock(form_blocks.DateTimeFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Date and Time")
+        icon = "fa-calendar"
+
+    field_class = CoderedDateTimeField
+    widget = CoderedDateTimeInput
+
+
+class CoderedStreamFormImageFieldBlock(form_blocks.ImageFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Image Upload")
+        icon = "fa-picture-o"
+
+
+class CoderedStreamFormFileFieldBlock(form_blocks.FileFieldBlock, FormBlockMixin):
+    class Meta:
+        label = _("Secure File Upload")
+        icon = "fa-upload"
+
+    field_class = SecureFileField
+
+
+class CoderedStreamFormStepBlock(form_blocks.FormStepBlock):
+    form_fields = blocks.StreamBlock()
+
+    def __init__(self, local_blocks=None, **kwargs):
+        super().__init__(
+            local_blocks = [
+                ('form_fields', blocks.StreamBlock(local_blocks))
+            ]
+        )

+ 2 - 0
coderedcms/forms.py

@@ -122,6 +122,8 @@ class CoderedFormBuilder(FormBuilder):
 
 
 
 
 class CoderedSubmissionsListView(WagtailSubmissionsListView):
 class CoderedSubmissionsListView(WagtailSubmissionsListView):
+
+    
     def get_csv_response(self, context):
     def get_csv_response(self, context):
         filename = self.get_csv_filename()
         filename = self.get_csv_filename()
         response = HttpResponse(content_type='text/csv; charset=utf-8')
         response = HttpResponse(content_type='text/csv; charset=utf-8')

+ 54 - 0
coderedcms/migrations/0015_coderedsessionformsubmission_coderedsubmissionrevision.py

@@ -0,0 +1,54 @@
+# Generated by Django 2.2.1 on 2019-05-04 15:09
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('wagtailcore', '0041_group_collection_permissions_verbose_name_plural'),
+        ('coderedcms', '0014_classifiers'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CoderedSubmissionRevision',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('type', models.CharField(choices=[('created', 'Created'), ('changed', 'Changed'), ('deleted', 'Deleted')], max_length=7)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('submission_id', models.TextField()),
+                ('data', models.TextField()),
+                ('summary', models.TextField()),
+                ('submission_ct', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+            ],
+            options={
+                'ordering': ('-created_at',),
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='CoderedSessionFormSubmission',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('form_data', models.TextField()),
+                ('submit_time', models.DateTimeField(auto_now_add=True, verbose_name='submit time')),
+                ('session_key', models.CharField(default=None, max_length=40, null=True)),
+                ('thumbnails_by_path', models.TextField(default='{}')),
+                ('last_modification', models.DateTimeField(auto_now=True, verbose_name='last modification')),
+                ('status', models.CharField(choices=[('incomplete', 'Not submitted'), ('complete', 'Complete'), ('reviewed', 'Under consideration'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='incomplete', max_length=10)),
+                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.Page')),
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'form submission',
+                'unique_together': {('page', 'session_key'), ('page', 'user')},
+                'abstract': False,
+                'verbose_name_plural': 'form submissions',
+            },
+        ),
+    ]

+ 348 - 136
coderedcms/models/page_models.py

@@ -15,7 +15,9 @@ from django.core.paginator import Paginator
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
-from django.http import JsonResponse
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+from django.http import JsonResponse, HttpResponseRedirect
 from django.shortcuts import render, redirect
 from django.shortcuts import render, redirect
 from django.template import Context, Template
 from django.template import Context, Template
 from django.template.loader import render_to_string
 from django.template.loader import render_to_string
@@ -27,6 +29,7 @@ from eventtools.models import BaseEvent, BaseOccurrence
 from icalendar import Event as ICalEvent
 from icalendar import Event as ICalEvent
 from modelcluster.fields import ParentalKey, ParentalManyToManyField
 from modelcluster.fields import ParentalKey, ParentalManyToManyField
 from modelcluster.tags import ClusterTaggableManager
 from modelcluster.tags import ClusterTaggableManager
+from pathlib import Path
 from taggit.models import TaggedItemBase
 from taggit.models import TaggedItemBase
 from wagtail.admin.edit_handlers import (
 from wagtail.admin.edit_handlers import (
     HelpPanel,
     HelpPanel,
@@ -45,7 +48,7 @@ from wagtail.core.utils import resolve_model_string
 from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel
 from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel
 from wagtail.contrib.forms.forms import WagtailAdminFormPageForm
 from wagtail.contrib.forms.forms import WagtailAdminFormPageForm
 from wagtail.images.edit_handlers import ImageChooserPanel
 from wagtail.images.edit_handlers import ImageChooserPanel
-from wagtail.contrib.forms.models import FormSubmission
+from wagtail.contrib.forms.models import AbstractFormSubmission, FormSubmission
 from wagtail.search import index
 from wagtail.search import index
 from wagtailcache.cache import WagtailCacheMixin
 from wagtailcache.cache import WagtailCacheMixin
 
 
@@ -53,13 +56,17 @@ from coderedcms import schema, utils
 from coderedcms.blocks import (
 from coderedcms.blocks import (
     CONTENT_STREAMBLOCKS,
     CONTENT_STREAMBLOCKS,
     LAYOUT_STREAMBLOCKS,
     LAYOUT_STREAMBLOCKS,
+    STREAMFORM_BLOCKS,
     ContentWallBlock,
     ContentWallBlock,
     OpenHoursBlock,
     OpenHoursBlock,
-    StructuredDataActionBlock)
+    StructuredDataActionBlock,
+    CoderedStreamFormStepBlock)
 from coderedcms.fields import ColorField
 from coderedcms.fields import ColorField
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
 from coderedcms.models.snippet_models import ClassifierTerm
 from coderedcms.models.snippet_models import ClassifierTerm
 from coderedcms.models.wagtailsettings_models import GeneralSettings, LayoutSettings, SeoSettings, GoogleApiSettings
 from coderedcms.models.wagtailsettings_models import GeneralSettings, LayoutSettings, SeoSettings, GoogleApiSettings
+from coderedcms.wagtail_flexible_forms.blocks import FormFieldBlock, FormStepBlock
+from coderedcms.wagtail_flexible_forms.models import Step, Steps, StreamFormMixin, StreamFormJSONEncoder, SessionFormSubmission, SubmissionRevision
 from coderedcms.settings import cr_settings
 from coderedcms.settings import cr_settings
 from coderedcms.widgets import ClassifierSelectWidget
 from coderedcms.widgets import ClassifierSelectWidget
 
 
@@ -451,7 +458,6 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         to enable customization by subclasses.
         to enable customization by subclasses.
         """
         """
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-
         klassname = self.__class__.__name__.lower()
         klassname = self.__class__.__name__.lower()
         template_choices = cr_settings['FRONTEND_TEMPLATES_PAGES'].get('*', ()) + \
         template_choices = cr_settings['FRONTEND_TEMPLATES_PAGES'].get('*', ()) + \
                            cr_settings['FRONTEND_TEMPLATES_PAGES'].get(klassname, ())
                            cr_settings['FRONTEND_TEMPLATES_PAGES'].get(klassname, ())
@@ -995,26 +1001,15 @@ class CoderedEventOccurrence(Orderable, BaseOccurrence):
         abstract = True
         abstract = True
 
 
 
 
-class CoderedFormPage(CoderedWebPage):
-    """
-    This is basically a clone of wagtail.contrib.forms.models.AbstractForm
-    with changes in functionality and extending CoderedWebPage vs wagtailcore.Page.
-    """
-    class Meta:
-        verbose_name = _('CodeRed Form Page')
-        abstract = True
-
-    template = 'coderedcms/pages/form_page.html'
-    landing_page_template = 'coderedcms/pages/form_page_landing.html'
-
-    base_form_class = WagtailAdminFormPageForm
+class CoderedFormMixin(models.Model):
 
 
-    form_builder = CoderedFormBuilder
+    class Meta:
+        abstract=True
 
 
     submissions_list_view_class = CoderedSubmissionsListView
     submissions_list_view_class = CoderedSubmissionsListView
+    encoder = DjangoJSONEncoder
 
 
     ### Custom codered fields
     ### Custom codered fields
-
     to_address = models.CharField(
     to_address = models.CharField(
         max_length=255,
         max_length=255,
         blank=True,
         blank=True,
@@ -1096,9 +1091,8 @@ class CoderedFormPage(CoderedWebPage):
         help_text=_('Date and time when the FORM will no longer be available on the page.'),
         help_text=_('Date and time when the FORM will no longer be available on the page.'),
     )
     )
 
 
-    body_content_panels = CoderedWebPage.body_content_panels + [
-        FormSubmissionsPanel(),
-        InlinePanel('form_fields', label="Form fields"),
+
+    body_content_panels = [
         MultiFieldPanel(
         MultiFieldPanel(
             [
             [
                 PageChooserPanel('thank_you_page'),
                 PageChooserPanel('thank_you_page'),
@@ -1120,10 +1114,9 @@ class CoderedFormPage(CoderedWebPage):
             ],
             ],
             _('Form Submissions')
             _('Form Submissions')
         ),
         ),
-        InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
     ]
     ]
 
 
-    settings_panels = CoderedPage.settings_panels + [
+    settings_panels = [
         MultiFieldPanel(
         MultiFieldPanel(
             [
             [
                 FieldRowPanel(
                 FieldRowPanel(
@@ -1147,92 +1140,40 @@ class CoderedFormPage(CoderedWebPage):
         return (self.form_golive_at is None or self.form_golive_at <= timezone.now()) and \
         return (self.form_golive_at is None or self.form_golive_at <= timezone.now()) and \
                (self.form_expire_at is None or self.form_expire_at >= timezone.now())
                (self.form_expire_at is None or self.form_expire_at >= timezone.now())
 
 
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        if not hasattr(self, 'landing_page_template'):
-            name, ext = os.path.splitext(self.template)
-            self.landing_page_template = name + '_landing' + ext
-
-    def get_form_fields(self):
-        """
-        Form page expects `form_fields` to be declared.
-        If you want to change backwards relation name,
-        you need to override this method.
-        """
-
-        return self.form_fields.all()
-
-    def get_data_fields(self):
-        """
-        Returns a list of tuples with (field_name, field_label).
-        """
-
-        data_fields = [
-            ('submit_time', _('Submission date')),
-        ]
-        data_fields += [
-            (field.clean_name, field.label)
-            for field in self.get_form_fields()
-        ]
-
-        return data_fields
-
-    def get_form_class(self):
-        fb = self.form_builder(self.get_form_fields())
-        return fb.get_form_class()
-
-    def get_form_parameters(self):
-        return {}
-
-    def get_form(self, *args, **kwargs):
-        form_class = self.get_form_class()
-        form_params = self.get_form_parameters()
-        form_params.update(kwargs)
-
-        return form_class(*args, **form_params)
-
     def get_landing_page_template(self, request, *args, **kwargs):
     def get_landing_page_template(self, request, *args, **kwargs):
         return self.landing_page_template
         return self.landing_page_template
 
 
-    def get_submission_class(self):
-        """
-        Returns submission class.
-
-        You can override this method to provide custom submission class.
-        Your class must be inherited from AbstractFormSubmission.
-        """
-
-        return FormSubmission
-
-    def process_form_submission(self, request, form):
-        """
-        Accepts form instance with submitted data, user and page.
-        Creates submission instance.
-
-        You can override this method if you want to have custom creation logic.
-        For example, if you want to save reference to a user.
-        """
+    def process_data(self, form, request):
         processed_data = {}
         processed_data = {}
-
         # Handle file uploads
         # Handle file uploads
         for key, val in form.cleaned_data.items():
         for key, val in form.cleaned_data.items():
+
             if type(val) == InMemoryUploadedFile or type(val) == TemporaryUploadedFile:
             if type(val) == InMemoryUploadedFile or type(val) == TemporaryUploadedFile:
                 # Save the file and get its URL
                 # Save the file and get its URL
-                file_system = FileSystemStorage(
-                    location=cr_settings['PROTECTED_MEDIA_ROOT'],
-                    base_url=cr_settings['PROTECTED_MEDIA_URL']
-                )
-                filename = file_system.save(file_system.get_valid_name(val.name), val)
-                processed_data[key] = file_system.url(filename)
+
+                directory = request.session.session_key
+                storage = self.get_storage()
+                Path(storage.path(directory)).mkdir(parents=True,
+                                                    exist_ok=True)
+                path = storage.get_available_name(
+                    str(Path(directory) / val.name))
+                with storage.open(path, 'wb+') as destination:
+                    for chunk in val.chunks():
+                        destination.write(chunk)
+
+                processed_data[key] = "{0}{1}".format(cr_settings['PROTECTED_MEDIA_URL'], path)
             else:
             else:
                 processed_data[key] = val
                 processed_data[key] = val
 
 
-        # Get submission
-        form_submission = self.get_submission_class()(
-            form_data=json.dumps(processed_data, cls=DjangoJSONEncoder),
-            page=self,
-        )
+        return processed_data
+
+    def get_storage(self):
+        return FileSystemStorage(
+                location=cr_settings['PROTECTED_MEDIA_ROOT'],
+                base_url=cr_settings['PROTECTED_MEDIA_URL']
+            )
+
+    def process_form_submission(self, request, form, form_submission, processed_data):
 
 
         # Save to database
         # Save to database
         if self.save_to_database:
         if self.save_to_database:
@@ -1244,7 +1185,7 @@ class CoderedFormPage(CoderedWebPage):
 
 
         if self.confirmation_emails:
         if self.confirmation_emails:
             # Convert form data into a context.
             # Convert form data into a context.
-            context = Context(self.data_to_dict(processed_data))
+            context = Context(self.data_to_dict(processed_data, request))
             # Render emails as if they are django templates.
             # Render emails as if they are django templates.
             for email in self.confirmation_emails.all():
             for email in self.confirmation_emails.all():
 
 
@@ -1291,24 +1232,20 @@ class CoderedFormPage(CoderedWebPage):
         for fn in hooks.get_hooks('form_page_submit'):
         for fn in hooks.get_hooks('form_page_submit'):
             fn(instance=self, form_submission=form_submission)
             fn(instance=self, form_submission=form_submission)
 
 
-        return form_submission
-
     def send_summary_mail(self, request, form, processed_data):
     def send_summary_mail(self, request, form, processed_data):
         """
         """
         Sends a form submission summary email.
         Sends a form submission summary email.
         """
         """
         addresses = [x.strip() for x in self.to_address.split(',')]
         addresses = [x.strip() for x in self.to_address.split(',')]
         content = []
         content = []
-        for field in form:
-            value = processed_data[field.name]
-            # Convert lists into human readable comma separated strings.
-            if isinstance(value, list):
-                value = ', '.join(value)
+
+        for key, value in self.data_to_dict(processed_data, request).items():
             content.append('{0}: {1}'.format(
             content.append('{0}: {1}'.format(
-                field.label,
-                utils.attempt_protected_media_value_conversion(request, value)
+                key.replace('_', ' ').title(),
+                value
             ))
             ))
-        content = '\n\n'.join(content)
+
+        content = '\n-------------------- \n'.join(content)
 
 
         # Build email message parameters
         # Build email message parameters
         message_args = {
         message_args = {
@@ -1324,7 +1261,7 @@ class CoderedFormPage(CoderedWebPage):
             message_args['from_email'] = genemail
             message_args['from_email'] = genemail
         if self.reply_address:
         if self.reply_address:
             # Render reply-to field using form submission as context.
             # Render reply-to field using form submission as context.
-            context = Context(self.data_to_dict(processed_data))
+            context = Context(self.data_to_dict(processed_data, request))
             template_reply_to = Template(self.reply_address)
             template_reply_to = Template(self.reply_address)
             message_args['reply_to'] = template_reply_to.render(context).split(',')
             message_args['reply_to'] = template_reply_to.render(context).split(',')
 
 
@@ -1332,21 +1269,8 @@ class CoderedFormPage(CoderedWebPage):
         message = EmailMessage(**message_args)
         message = EmailMessage(**message_args)
         message.send()
         message.send()
 
 
-    def data_to_dict(self, processed_data):
-        """
-        Converts processed form data into a dictionary suitable
-        for rendering in a context.
-        """
-        dictionary = {}
-
-        for key, value in processed_data.items():
-            dictionary[key.replace('-', '_')] = value
-            if isinstance(value, list):
-                dictionary[key] = ', '.join(value)
-
-        return dictionary
-
     def render_landing_page(self, request, form_submission=None, *args, **kwargs):
     def render_landing_page(self, request, form_submission=None, *args, **kwargs):
+
         """
         """
         Renders the landing page.
         Renders the landing page.
 
 
@@ -1365,22 +1289,139 @@ class CoderedFormPage(CoderedWebPage):
         )
         )
         return response
         return response
 
 
+    def data_to_dict(self, processed_data, request):
+        """
+        Converts processed form data into a dictionary suitable
+        for rendering in a context.
+        """
+        dictionary = {}
+
+        for key, value in processed_data.items():
+            new_key = key.replace('-', '_')
+            if isinstance(value, list):
+                dictionary[new_key] = ', '.join(value)
+            else:
+                dictionary[new_key] = utils.attempt_protected_media_value_conversion(request, value)
+
+        return dictionary
+
+    preview_modes = [
+        ('form', _('Form')),
+        ('landing', _('Thank you page')),
+    ]
+
+    def serve_preview(self, request, mode):
+        if mode == 'landing':
+            request.is_preview = True
+            return self.render_landing_page(request)
+
+        return super().serve_preview(request, mode)
+
     def serve_submissions_list_view(self, request, *args, **kwargs):
     def serve_submissions_list_view(self, request, *args, **kwargs):
         """
         """
         Returns list submissions view for admin.
         Returns list submissions view for admin.
 
 
-        `list_submissions_view_class` can bse set to provide custom view class.
+        `list_submissions_view_class` can be set to provide custom view class.
         Your class must be inherited from SubmissionsListView.
         Your class must be inherited from SubmissionsListView.
         """
         """
         view = self.submissions_list_view_class.as_view()
         view = self.submissions_list_view_class.as_view()
         return view(request, form_page=self, *args, **kwargs)
         return view(request, form_page=self, *args, **kwargs)
 
 
+class CoderedFormPage(CoderedFormMixin, CoderedWebPage):
+    """
+    This is basically a clone of wagtail.contrib.forms.models.AbstractForm
+    with changes in functionality and extending CoderedWebPage vs wagtailcore.Page.
+    """
+    class Meta:
+        verbose_name = _('CodeRed Form Page')
+        abstract = True
+
+    template = 'coderedcms/pages/form_page.html'
+    landing_page_template = 'coderedcms/pages/form_page_landing.html'
+
+    base_form_class = WagtailAdminFormPageForm
+
+    form_builder = CoderedFormBuilder
+
+    body_content_panels = [
+            InlinePanel('form_fields', label="Form fields"),
+        ] + \
+        CoderedWebPage.body_content_panels + \
+        CoderedFormMixin.body_content_panels + [
+            FormSubmissionsPanel(),
+            InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
+        ]
+
+    settings_panels = CoderedPage.settings_panels + CoderedFormMixin.settings_panels
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if not hasattr(self, 'landing_page_template'):
+            name, ext = os.path.splitext(self.template)
+            self.landing_page_template = name + '_landing' + ext
+
+    def get_form_fields(self):
+        """
+        Form page expects `form_fields` to be declared.
+        If you want to change backwards relation name,
+        you need to override this method.
+        """
+
+        return self.form_fields.all()
+
+    def get_data_fields(self):
+        """
+        Returns a list of tuples with (field_name, field_label).
+        """
+
+        data_fields = [
+            ('submit_time', _('Submission date')),
+        ]
+        data_fields += [
+            (field.clean_name, field.label)
+            for field in self.get_form_fields()
+        ]
+        return data_fields
+
+    def get_form_class(self):
+        fb = self.form_builder(self.get_form_fields())
+        return fb.get_form_class()
+
+    def get_form_parameters(self):
+        return {}
+
+    def get_form(self, *args, **kwargs):
+        form_class = self.get_form_class()
+        form_params = self.get_form_parameters()
+        form_params.update(kwargs)
+
+        return form_class(*args, **form_params)
+
+    def get_submission_class(self):
+        """
+        Returns submission class.
+
+        You can override this method to provide custom submission class.
+        Your class must be inherited from AbstractFormSubmission.
+        """
+
+        return FormSubmission
+
     def serve(self, request, *args, **kwargs):
     def serve(self, request, *args, **kwargs):
         if request.method == 'POST':
         if request.method == 'POST':
             form = self.get_form(request.POST, request.FILES, page=self, user=request.user)
             form = self.get_form(request.POST, request.FILES, page=self, user=request.user)
 
 
             if form.is_valid():
             if form.is_valid():
-                form_submission = self.process_form_submission(request, form)
+                processed_data = self.process_data(form, request)
+                form_submission = self.get_submission_class()(
+                    form_data=json.dumps(processed_data, cls=self.encoder),
+                    page=self,
+                )
+                self.process_form_submission(
+                    request=request,
+                    form=form,
+                    form_submission=form_submission,
+                    processed_data=processed_data)
                 return self.render_landing_page(request, form_submission, *args, **kwargs)
                 return self.render_landing_page(request, form_submission, *args, **kwargs)
         else:
         else:
             form = self.get_form(page=self, user=request.user)
             form = self.get_form(page=self, user=request.user)
@@ -1394,18 +1435,189 @@ class CoderedFormPage(CoderedWebPage):
         )
         )
         return response
         return response
 
 
-    preview_modes = [
-        ('form', _('Form')),
-        ('landing', _('Thank you page')),
-    ]
+class CoderedSubmissionRevision(SubmissionRevision, models.Model):
+    pass
 
 
-    def serve_preview(self, request, mode):
-        if mode == 'landing':
-            request.is_preview = True
-            return self.render_landing_page(request)
 
 
-        return super().serve_preview(request, mode)
+class CoderedSessionFormSubmission(SessionFormSubmission):
+
+    INCOMPLETE = 'incomplete'
+    COMPLETE = 'complete'
+    REVIEWED = 'reviewed'
+    APPROVED = 'approved'
+    REJECTED = 'rejected'
+    STATUSES = (
+        (INCOMPLETE, _('Not submitted')),
+        (COMPLETE, _('Complete')),
+        (REVIEWED, _('Under consideration')),
+        (APPROVED, _('Approved')),
+        (REJECTED, _('Rejected')),
+    )
+    status = models.CharField(max_length=10, choices=STATUSES, default=INCOMPLETE)
+
+    def create_normal_submission(self, delete_self=True):
+        submission_data = self.get_data()
+        if 'user' in submission_data:
+            submission_data['user'] = str(submission_data['user'])
+        submission = FormSubmission.objects.create(
+                form_data=json.dumps(submission_data, cls=StreamFormJSONEncoder),
+                page=self.page
+            )
+
+        if delete_self:
+            CoderedSubmissionRevision.objects.filter(submission_id=self.id).delete()
+            self.delete()
+
+        return submission
+
+    def render_email(self, value):
+        return value
+
+    def render_link(self, value):
+        return "{0}{1}".format(cr_settings['PROTECTED_MEDIA_URL'], value)
+
+
+    def render_image(self, value):
+        return "{0}{1}".format(cr_settings['PROTECTED_MEDIA_URL'], value)
+
 
 
+    def render_file(self, value):
+        return "{0}{1}".format(cr_settings['PROTECTED_MEDIA_URL'], value)
+
+
+@receiver(post_save)
+def create_submission_changed_revision(sender, **kwargs):
+    if not issubclass(sender, SessionFormSubmission):
+        return
+    submission = kwargs['instance']
+    created = kwargs['created']
+    CoderedSubmissionRevision.create_from_submission(
+        submission, (CoderedSubmissionRevision.CREATED if created
+                     else CoderedSubmissionRevision.CHANGED))
+
+
+@receiver(post_delete)
+def create_submission_deleted_revision(sender, **kwargs):
+    if not issubclass(sender, CoderedSessionFormSubmission):
+        return
+    submission = kwargs['instance']
+    CoderedSubmissionRevision.create_from_submission(submission,
+                                              SubmissionRevision.DELETED)
+
+
+class CoderedStep(Step):
+
+    def get_markups_and_bound_fields(self, form):
+        for struct_child in self.form_fields:
+            block = struct_child.block
+            if isinstance(block, FormFieldBlock):
+                struct_value = struct_child.value
+                field_name = block.get_slug(struct_value)
+                yield form[field_name], 'field', struct_child
+            else:
+                yield mark_safe(struct_child), 'markup'
+
+
+class CoderedSteps(Steps):
+
+    def __init__(self, page, request=None):
+        self.page = page
+        # TODO: Make it possible to change the `form_fields` attribute.
+        self.form_fields = page.form_fields
+        self.request = request
+        has_steps = any(isinstance(struct_child.block, FormStepBlock)
+                        for struct_child in self.form_fields)
+        if has_steps:
+            steps = [CoderedStep(self, i, form_field)
+                     for i, form_field in enumerate(self.form_fields)]
+        else:
+            steps = [CoderedStep(self, 0, self.form_fields)]
+        super(Steps, self).__init__(steps)
+
+
+class CoderedStreamFormMixin(StreamFormMixin):
+    class Meta:
+        abstract=True
+
+    def get_steps(self, request=None):
+        if not hasattr(self, 'steps'):
+            steps = CoderedSteps(self, request=request)
+            if request is None:
+                return steps
+            self.steps = steps
+        return self.steps
+
+    @staticmethod
+    def get_submission_class():
+        return FormSubmission
+
+    @staticmethod
+    def get_session_submission_class():
+        return CoderedSessionFormSubmission
+
+    def get_submission(self, request):
+        Submission = self.get_session_submission_class()
+        if request.user.is_authenticated:
+            user_submission = Submission.objects.filter(
+                user=request.user, page=self).order_by('-pk').first()
+            if user_submission is None:
+                return Submission(user=request.user, page=self, form_data='[]')
+            return user_submission
+
+        # Custom code to ensure that anonymous users get a session key.
+        if not request.session.session_key:
+            request.session.create()
+
+        user_submission = Submission.objects.filter(
+            session_key=request.session.session_key, page=self
+        ).order_by('-pk').first()
+        if user_submission is None:
+            return Submission(session_key=request.session.session_key,
+                              page=self, form_data='[]')
+        return user_submission
+
+
+class CoderedStreamFormPage(CoderedStreamFormMixin, CoderedFormMixin, CoderedWebPage):
+    class Meta:
+        verbose_name = _('CodeRed Advanced Form Page')
+        abstract = True
+
+    template = 'coderedcms/pages/stream_form_page.html'
+    landing_page_template = 'coderedcms/pages/form_page_landing.html'
+
+    form_fields = StreamField(STREAMFORM_BLOCKS)
+    encoder = StreamFormJSONEncoder
+
+    body_content_panels = [
+        StreamFieldPanel('form_fields')
+    ] + \
+    CoderedFormMixin.body_content_panels + [
+        InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
+    ]
+
+    def serve(self, request, *args, **kwargs):
+        context = self.get_context(request)
+        form = context['form']
+        if request.method == 'POST' and form.is_valid():
+            is_complete = self.steps.update_data()
+            if is_complete:
+                submission = self.get_submission(request)
+                self.process_form_submission(
+                    request=request,
+                    form=form,
+                    form_submission=submission,
+                    processed_data=submission.get_data()
+                )
+                normal_submission = submission.create_normal_submission()
+                return self.render_landing_page(request, normal_submission, *args, **kwargs)
+            return HttpResponseRedirect(self.url)
+        return CoderedWebPage.serve(self, request, *args, **kwargs)
+
+    def get_storage(self):
+        return FileSystemStorage(
+                location=cr_settings['PROTECTED_MEDIA_ROOT'],
+                base_url=cr_settings['PROTECTED_MEDIA_URL']
+            )
 
 
 class CoderedLocationPage(CoderedWebPage):
 class CoderedLocationPage(CoderedWebPage):
     """
     """

+ 34 - 18
coderedcms/models/tests/test_page_models.py

@@ -9,21 +9,23 @@ from coderedcms.models.page_models import (
     CoderedEventIndexPage,
     CoderedEventIndexPage,
     CoderedEventPage,
     CoderedEventPage,
     CoderedFormPage,
     CoderedFormPage,
-    CoderedPage,
-    CoderedWebPage,
     CoderedLocationIndexPage,
     CoderedLocationIndexPage,
     CoderedLocationPage,
     CoderedLocationPage,
+    CoderedPage,
+    CoderedStreamFormPage,
+    CoderedWebPage,
     get_page_models
     get_page_models
 )
 )
 from coderedcms.tests.testapp.models import (
 from coderedcms.tests.testapp.models import (
-    ArticlePage,
     ArticleIndexPage,
     ArticleIndexPage,
-    FormPage,
-    WebPage,
-    EventPage,
+    ArticlePage,
     EventIndexPage,
     EventIndexPage,
+    EventPage,
+    FormPage,
+    LocationIndexPage,
     LocationPage,
     LocationPage,
-    LocationIndexPage
+    StreamFormPage,
+    WebPage
 )
 )
 
 
 class BasicPageTestCase():
 class BasicPageTestCase():
@@ -46,6 +48,7 @@ class BasicPageTestCase():
         Tests to make sure a basic version of the page serves a 200 from a GET request.
         Tests to make sure a basic version of the page serves a 200 from a GET request.
         """
         """
         request = self.request_factory.get(self.basic_page.url)
         request = self.request_factory.get(self.basic_page.url)
+        request.session = self.client.session
         request.user = AnonymousUser()
         request.user = AnonymousUser()
         request.site = Site.objects.all()[0]
         request.site = Site.objects.all()[0]
         response = self.basic_page.serve(request)
         response = self.basic_page.serve(request)
@@ -85,6 +88,21 @@ class ConcreteBasicPageTestCase(ConcretePageTestCase, BasicPageTestCase):
     class Meta:
     class Meta:
         abstract=True
         abstract=True
 
 
+class ConcreteFormPageTestCase(ConcreteBasicPageTestCase):
+    class Meta:
+        abstract=True
+
+    def test_post(self):
+        """
+        Tests to make sure a basic version of the page serves a 200 from a POST request.
+        """
+        request = self.request_factory.post(self.basic_page.url)
+        request.session = self.client.session
+        request.user = AnonymousUser()
+        request.site = Site.objects.all()[0]
+        response = self.basic_page.serve(request)
+        self.assertEqual(response.status_code, 200)
+
 class CoderedArticleIndexPageTestCase(AbstractPageTestCase, WagtailPageTests):
 class CoderedArticleIndexPageTestCase(AbstractPageTestCase, WagtailPageTests):
     model = CoderedArticleIndexPage
     model = CoderedArticleIndexPage
 
 
@@ -125,6 +143,10 @@ class CoderedEventPageTestCase(AbstractPageTestCase, WagtailPageTests):
     model = CoderedEventPage
     model = CoderedEventPage
 
 
 
 
+class CoderedStreamFormPageTestCase(AbstractPageTestCase, WagtailPageTests):
+    model = CoderedStreamFormPage
+
+
 class ArticlePageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
 class ArticlePageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     model = ArticlePage
     model = ArticlePage
 
 
@@ -133,19 +155,9 @@ class ArticleIndexPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     model = ArticleIndexPage
     model = ArticleIndexPage
 
 
 
 
-class FormPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
+class FormPageTestCase(ConcreteFormPageTestCase, WagtailPageTests):
     model = FormPage
     model = FormPage
 
 
-    def test_post(self):
-        """
-        Tests to make sure a basic version of the page serves a 200 from a POST request.
-        """
-        request = self.request_factory.post(self.basic_page.url)
-        request.user = AnonymousUser()
-        request.site = Site.objects.all()[0]
-        response = self.basic_page.serve(request)
-        self.assertEqual(response.status_code, 200)
-
 
 
 class WebPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
 class WebPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     model = WebPage
     model = WebPage
@@ -165,3 +177,7 @@ class LocationIndexPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
 
 
 class LocationPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
 class LocationPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     model = LocationPage
     model = LocationPage
+
+
+class StreamFormPageTestCase(ConcreteFormPageTestCase, WagtailPageTests):
+    model = StreamFormPage

+ 8 - 0
coderedcms/static/coderedcms/css/codered-editor.css

@@ -161,6 +161,14 @@ li.sequence-member .struct-block .fields {
     font-weight:normal;
     font-weight:normal;
 }
 }
 
 
+.stream-menu-inner h3 {
+    color: #ccc;
+    text-transform: uppercase;
+    font-weight: 700;
+    border-bottom: 2px solid rgba(255,255,255,0.1);
+    padding: 0.5em;
+}
+
 
 
 /* Draftail */
 /* Draftail */
 
 

+ 8 - 0
coderedcms/static/coderedcms/js/codered-front.js

@@ -46,6 +46,10 @@ libs = {
     coderedmaps: {
     coderedmaps: {
         url: "/static/coderedcms/js/codered-maps.js",
         url: "/static/coderedcms/js/codered-maps.js",
         integrity: "",
         integrity: "",
+    },
+    coderedstreamforms: {
+        url: "/static/coderedcms/js/codered-streamforms.js",
+        integrity: "",
     }
     }
 }
 }
 
 
@@ -202,6 +206,10 @@ $(document).ready(function()
         });
         });
     }
     }
 
 
+    if ($('.stream-form-input').length > 0){
+        load_script(libs.coderedstreamforms);
+    }
+
     /*** Lightbox ***/
     /*** Lightbox ***/
     $('.lightbox-preview').on('click', function(event) {
     $('.lightbox-preview').on('click', function(event) {
         var orig_src = $(this).find('img').data('original-src');
         var orig_src = $(this).find('img').data('original-src');

+ 47 - 0
coderedcms/static/coderedcms/js/codered-streamforms.js

@@ -0,0 +1,47 @@
+condition_triggered = function($source_field, $target_field) {
+    // custom logic for checkboxes since `.val()` always returns a fixed value, :checked property
+    // must be evaluated instead.
+    if ($source_field.prop("type") == "checkbox") {
+        $source_field.each(function() {
+            var $source_field = $(this);
+            $trigger_checkbox = $source_field.closest("[value='" + $target_field.data("condition-trigger-value") + "']");
+            if ($trigger_checkbox.length > 0) {
+                if($trigger_checkbox.prop("checked")) {
+                    $target_field.show();
+                }
+                else {
+                    $target_field.hide();
+                }
+            }
+        });
+    }
+    else {
+        if ($source_field.val().trim() == $target_field.data("condition-trigger-value").trim()) {
+            $target_field.show();
+        }
+        else {
+            $target_field.hide();
+        }
+    }
+}
+
+$("[data-condition-trigger-id]").each(function () {
+
+    // Get source/target fields from data attributes.
+    var $target_field = $(this);
+    var source_query = "#" + $(this).data("condition-trigger-id");
+    var $source_field = $(source_query + " input, " + source_query + " textarea, " + source_query + " select");
+    var source_field_name = $source_field.prop("name");
+
+    // Trigger initial state of input.
+    condition_triggered($source_field, $target_field);
+
+    // Watch change event for similarly named inputs within this form.
+    // It is necessary to watch based on name
+    // because selecting another radio button does not trigger a `change` for other radio buttons,
+    // it only triggers a change for the whole radio group (identified by "name").
+    var $form = $(this).closest("form");
+    $form.find("[name='" + source_field_name + "']").change(function () {
+        condition_triggered($(this), $target_field);
+    });
+});

+ 1 - 1
coderedcms/templates/coderedcms/blocks/quote_block.html

@@ -1,6 +1,6 @@
 {% block block_render %}
 {% block block_render %}
     <blockquote class="blockquote {{self.settings.custom_css_class}}"
     <blockquote class="blockquote {{self.settings.custom_css_class}}"
-    {% if self.settings.custom_css_id %}id="{{self.settings.custom_css_id}}"{% endif %}>
+    {% if self.settings.custom_id %}id="{{self.settings.custom_id}}"{% endif %}>
         <p class="mb-0">{{self.text}}</p>
         <p class="mb-0">{{self.text}}</p>
         {% if self.author %}<footer class="blockquote-footer">{{self.author}}</footer>{% endif %}
         {% if self.author %}<footer class="blockquote-footer">{{self.author}}</footer>{% endif %}
     </blockquote>
     </blockquote>

+ 9 - 0
coderedcms/templates/coderedcms/includes/stream_forms/render_field.html

@@ -0,0 +1,9 @@
+{% load bootstrap4 %}
+<div
+{% if block.value.settings.condition_trigger_id %}data-condition-trigger-id="{{ block.value.settings.condition_trigger_id }}"{% endif %}
+{% if block.value.settings.condition_trigger_value %}data-condition-trigger-value="{{ block.value.settings.condition_trigger_value }}"{% endif %}
+{% if block.value.settings.custom_id %}id="{{block.value.settings.custom_id}}"{% endif %}
+class="stream-form-input{% if block.value.settings.custom_css_class %} {{ block.value.settings.custom_css_class }}{% endif %}"
+>
+{% bootstrap_field field layout='horizontal' %}
+</div>

+ 62 - 0
coderedcms/templates/coderedcms/pages/form_page.html

@@ -0,0 +1,62 @@
+{% extends "coderedcms/pages/web_page.html" %}
+
+{% load wagtailcore_tags coderedcms_tags %}
+
+{% block content_body %}
+
+{{ block.super }}
+
+{% if page.form_live %}
+
+
+<div class="container">
+    {% block progress_bar %}
+    {% if steps|length > 1 %}
+    <div class="progress" style="height: 40px;">
+        {% with last_step=steps|last %}
+
+        {% widthratio step.index|add:"1" last_step.index|add:"1" 100 as width %}
+        <div class="progress-bar" role="progressbar" style="width: {{ width }}%;" aria-valuenow="{{ width }}" aria-valuemin="{{ width }}" aria-valuemax="100"><span>&nbsp;Step {{step.index|add:"1"}} / {{last_step.index|add:"1"}}   ({{ width }}%)</span></div>
+        {% endwith %}
+    </div>
+    <br />
+    {% endif %}
+    {% endblock %}
+
+    {% block stream_form %}
+    <form class='stream-form {{ page.form_css_class }}' id='{{ page.form_id }}' action="{% pageurl self %}" method="POST" {% if form|is_file_form %}enctype="multipart/form-data"{% endif %}>
+        {% csrf_token %}
+
+        {% block stream_form_fields %}
+
+            {% for item in markups_and_bound_fields %}
+                {% if item.1 == 'markup' %}
+                    {% include_block item.0 %}
+                {% else %}
+                    {% include 'coderedcms/includes/stream_forms/render_field.html' with block=item.2 field=item.0 %}
+                {% endif %}
+            {% endfor %}
+
+        {% endblock %}
+
+        {% block stream_form_actions %}
+        <div class="form-group mt-5 row">
+            <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
+            <div class="{{'horizontal_field_class'|bootstrap_settings}}">
+                {% if step != steps|first %}
+                <a href='{{page.url}}?step={{step.index}}' class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
+                    Previous
+                </a>
+                {% endif %}
+                <button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
+                    {% if steps|last == step %}{{ page.button_text }}{% else %}Next{% endif %}
+                </button>
+            </div>
+        </div>
+        {% endblock %}
+    </form>
+    {% endblock %}
+</div>
+{% endif %}
+
+{% endblock %}

+ 4 - 2
coderedcms/templatetags/coderedcms_tags.py

@@ -1,12 +1,14 @@
 import string
 import string
 import random
 import random
+from html import unescape
+
+
 from datetime import datetime
 from datetime import datetime
 from django import template
 from django import template
 from django.conf import settings
 from django.conf import settings
 from django.forms import ClearableFileInput
 from django.forms import ClearableFileInput
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.html import mark_safe
 from django.utils.html import mark_safe
-from django.utils.formats import localize
 from wagtail.core.models import Collection
 from wagtail.core.models import Collection
 from wagtail.core.rich_text import RichText
 from wagtail.core.rich_text import RichText
 from wagtail.core.templatetags.wagtailcore_tags import richtext
 from wagtail.core.templatetags.wagtailcore_tags import richtext
@@ -100,7 +102,7 @@ def process_form_cell(request, cell):
         return utils.get_protected_media_link(request, cell, render_link=True)
         return utils.get_protected_media_link(request, cell, render_link=True)
     if utils.uri_validator(str(cell)):
     if utils.uri_validator(str(cell)):
         return mark_safe("<a href='{0}'>{1}</a>".format(cell, cell))
         return mark_safe("<a href='{0}'>{1}</a>".format(cell, cell))
-    return localize(cell)
+    return cell
 
 
 @register.filter
 @register.filter
 def codered_settings(value):
 def codered_settings(value):

文件差异内容过多而无法显示
+ 30 - 0
coderedcms/tests/testapp/migrations/0004_streamformconfirmemail_streamformpage.py


+ 13 - 3
coderedcms/tests/testapp/models.py

@@ -8,9 +8,10 @@ from coderedcms.models import (
     CoderedEventOccurrence,
     CoderedEventOccurrence,
     CoderedEmail,
     CoderedEmail,
     CoderedFormPage,
     CoderedFormPage,
-    CoderedWebPage,
     CoderedLocationIndexPage,
     CoderedLocationIndexPage,
-    CoderedLocationPage
+    CoderedLocationPage,
+    CoderedStreamFormPage,
+    CoderedWebPage
 )
 )
 
 
 
 
@@ -138,4 +139,13 @@ class LocationIndexPage(CoderedLocationIndexPage):
     # Only allow LocationPages beneath this page.
     # Only allow LocationPages beneath this page.
     subpage_types = ['testapp.LocationPage']
     subpage_types = ['testapp.LocationPage']
 
 
-    template = 'coderedcms/pages/location_index_page.html'
+    template = 'coderedcms/pages/location_index_page.html'
+
+class StreamFormPage(CoderedStreamFormPage):
+    class Meta:
+        verbose_name = 'Stream Form'
+
+    template = 'coderedcms/pages/stream_form_page.html'
+
+class StreamFormConfirmEmail(CoderedEmail):
+    page = ParentalKey('StreamFormPage', related_name='confirmation_emails')

+ 3 - 2
coderedcms/utils.py

@@ -19,13 +19,14 @@ def uri_validator(possible_uri):
         return False
         return False
 
 
 def attempt_protected_media_value_conversion(request, value):
 def attempt_protected_media_value_conversion(request, value):
-    new_value = value
     try:
     try:
         if value.startswith(cr_settings['PROTECTED_MEDIA_URL']):
         if value.startswith(cr_settings['PROTECTED_MEDIA_URL']):
             new_value = get_protected_media_link(request, value)
             new_value = get_protected_media_link(request, value)
+            return new_value
     except AttributeError:
     except AttributeError:
         pass
         pass
-    return new_value
+
+    return value
 
 
 def fix_ical_datetime_format(dt_str):
 def fix_ical_datetime_format(dt_str):
     """
     """

+ 29 - 0
coderedcms/wagtail_flexible_forms/LICENSE

@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2018, NoriPyt
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 269 - 0
coderedcms/wagtail_flexible_forms/blocks.py

@@ -0,0 +1,269 @@
+from django import forms
+from django.db.models import BLANK_CHOICE_DASH
+from django.utils.dateparse import parse_datetime
+from django.utils.encoding import force_text
+from django.utils.text import slugify
+from django.utils.translation import ugettext_lazy as _
+from unidecode import unidecode
+from wagtail.core.blocks import (
+    StructBlock, TextBlock, CharBlock, BooleanBlock, ListBlock, StreamBlock,
+    DateBlock, TimeBlock, DateTimeBlock, ChoiceBlock, RichTextBlock,
+)
+
+
+class FormFieldBlock(StructBlock):
+    field_label = CharBlock(label=_('Label'))
+    help_text = TextBlock(required=False, label=_('Help text'))
+
+    field_class = forms.CharField
+    widget = None
+
+    def get_slug(self, struct_value):
+        return force_text(slugify(unidecode(struct_value['field_label'])))
+
+    def get_field_class(self, struct_value):
+        return self.field_class
+
+    def get_widget(self, struct_value):
+        return self.widget
+
+    def get_field_kwargs(self, struct_value):
+        kwargs = {'label': struct_value['field_label'],
+                  'help_text': struct_value['help_text'],
+                  'required': struct_value.get('required', False)}
+        if 'default_value' in struct_value:
+            kwargs['initial'] = struct_value['default_value']
+        form_widget = self.get_widget(struct_value)
+        if form_widget is not None:
+            kwargs['widget'] = form_widget
+        return kwargs
+
+    def get_field(self, struct_value):
+        return self.get_field_class(struct_value)(
+            **self.get_field_kwargs(struct_value))
+
+
+class OptionalFormFieldBlock(FormFieldBlock):
+    required = BooleanBlock(label=_('Required'), required=False)
+
+
+CHARFIELD_FORMATS = [
+    ('email', _('Email')),
+    ('url', _('URL')),
+]
+try:
+    from phonenumber_field.formfields import PhoneNumberField
+except ImportError:
+    pass
+else:
+    CHARFIELD_FORMATS.append(('phone', _('Phone')))
+
+
+class CharFieldBlock(OptionalFormFieldBlock):
+    format = ChoiceBlock(choices=CHARFIELD_FORMATS, required=False, label=_('Format'))
+    default_value = CharBlock(required=False, label=_('Default value'))
+
+    class Meta:
+        label = _('Text field (single line)')
+
+    def get_field_class(self, struct_value):
+        text_format = struct_value['format']
+        if text_format == 'url':
+            return forms.URLField
+        if text_format == 'email':
+            return forms.EmailField
+        if text_format == 'phone':
+            return PhoneNumberField
+        return super().get_field_class(struct_value)
+
+
+class TextFieldBlock(OptionalFormFieldBlock):
+    default_value = TextBlock(required=False, label=_('Default value'))
+
+    widget = forms.Textarea(attrs={'rows': 5})
+
+    class Meta:
+        label = _('Text field (multi line)')
+
+
+class NumberFieldBlock(OptionalFormFieldBlock):
+    default_value = CharBlock(required=False, label=_('Default value'))
+
+    widget = forms.NumberInput
+
+    class Meta:
+        label = _('Number field')
+
+
+class CheckboxFieldBlock(FormFieldBlock):
+    default_value = BooleanBlock(required=False)
+
+    field_class = forms.BooleanField
+
+    class Meta:
+        label = _('Checkbox field')
+        icon = 'tick-inverse'
+
+
+class RadioButtonsFieldBlock(OptionalFormFieldBlock):
+    choices = ListBlock(CharBlock(label=_('Choice')))
+
+    field_class = forms.ChoiceField
+    widget = forms.RadioSelect
+
+    class Meta:
+        label = _('Radio buttons')
+        icon = 'radio-empty'
+
+    def get_field_kwargs(self, struct_value):
+        kwargs = super().get_field_kwargs(struct_value)
+        kwargs['choices'] = [(choice, choice)
+                             for choice in struct_value['choices']]
+        return kwargs
+
+
+class DropdownFieldBlock(RadioButtonsFieldBlock):
+    widget = forms.Select
+
+    class Meta:
+        label = _('Dropdown field')
+        icon = 'arrow-down-big'
+
+    def get_field_kwargs(self, struct_value):
+        kwargs = super(DropdownFieldBlock,
+                       self).get_field_kwargs(struct_value)
+        kwargs['choices'].insert(0, BLANK_CHOICE_DASH[0])
+        return kwargs
+
+
+class CheckboxesFieldBlock(OptionalFormFieldBlock):
+    checkboxes = ListBlock(CharBlock(label=_('Checkbox')))
+
+    field_class = forms.MultipleChoiceField
+    widget = forms.CheckboxSelectMultiple
+
+    class Meta:
+        label = _('Multiple checkboxes field')
+        icon = 'list-ul'
+
+    def get_field_kwargs(self, struct_value):
+        kwargs = super(CheckboxesFieldBlock,
+                       self).get_field_kwargs(struct_value)
+        kwargs['choices'] = [(choice, choice)
+                             for choice in struct_value['checkboxes']]
+        return kwargs
+
+
+class DatePickerInput(forms.DateInput):
+    def __init__(self, *args, **kwargs):
+        attrs = kwargs.get('attrs')
+        if attrs is None:
+            attrs = {}
+        attrs.update({
+            'data-provide': 'datepicker',
+            'data-date-format': 'yyyy-mm-dd',
+        })
+        kwargs['attrs'] = attrs
+        super().__init__(*args, **kwargs)
+
+
+class DateFieldBlock(OptionalFormFieldBlock):
+    default_value = DateBlock(required=False)
+
+    field_class = forms.DateField
+    widget = DatePickerInput
+
+    class Meta:
+        label = _('Date field')
+        icon = 'date'
+
+
+class HTML5TimeInput(forms.TimeInput):
+    input_type = 'time'
+
+
+class TimeFieldBlock(OptionalFormFieldBlock):
+    default_value = TimeBlock(required=False)
+
+    field_class = forms.TimeField
+    widget = HTML5TimeInput
+
+    class Meta:
+        label = _('Time field')
+        icon = 'time'
+
+
+class DateTimePickerInput(forms.SplitDateTimeWidget):
+    def __init__(self, attrs=None, date_format=None, time_format=None):
+        super().__init__(attrs=attrs,
+                         date_format=date_format, time_format=time_format)
+        self.widgets = (
+            DatePickerInput(attrs=attrs, format=date_format),
+            HTML5TimeInput(attrs=attrs, format=time_format),
+        )
+
+    def decompress(self, value):
+        if isinstance(value, str):
+            value = parse_datetime(value)
+        return super().decompress(value)
+
+
+class DateTimeFieldBlock(OptionalFormFieldBlock):
+    default_value = DateTimeBlock(required=False)
+
+    field_class = forms.SplitDateTimeField
+    widget = DateTimePickerInput
+
+    class Meta:
+        label = _('Date+time field')
+        icon = 'date'
+
+
+class ImageFieldBlock(OptionalFormFieldBlock):
+    field_class = forms.ImageField
+
+    class Meta:
+        label = _('Image field')
+        icon = 'image'
+
+
+class FileFieldBlock(OptionalFormFieldBlock):
+    field_class = forms.FileField
+
+    class Meta:
+        label = _('File field')
+        icon = 'download'
+
+
+class FormFieldsBlock(StreamBlock):
+    char = CharFieldBlock(group=_('Fields'))
+    text = TextFieldBlock(group=_('Fields'))
+    number = NumberFieldBlock(group=_('Fields'))
+    checkbox = CheckboxFieldBlock(group=_('Fields'))
+    radios = RadioButtonsFieldBlock(group=_('Fields'))
+    dropdown = DropdownFieldBlock(group=_('Fields'))
+    checkboxes = CheckboxesFieldBlock(group=_('Fields'))
+    date = DateFieldBlock(group=_('Fields'))
+    time = TimeFieldBlock(group=_('Fields'))
+    datetime = DateTimeFieldBlock(group=_('Fields'))
+    image = ImageFieldBlock(group=_('Fields'))
+    file = FileFieldBlock(group=_('Fields'))
+    text_markup = RichTextBlock(group=_('Other'))
+
+    class Meta:
+        label = _('Form fields')
+
+
+class FormStepBlock(StructBlock):
+    name = CharBlock(label=_('Name'), required=False)
+    form_fields = FormFieldsBlock()
+
+    class Meta:
+        label = _('Form step')
+
+
+class FormStepsBlock(StreamBlock):
+    step = FormStepBlock()
+
+    class Meta:
+        label = _('Form steps')

+ 30 - 0
coderedcms/wagtail_flexible_forms/edit_handlers.py

@@ -0,0 +1,30 @@
+from django.template.loader import render_to_string
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext as _
+
+from wagtail.admin.edit_handlers import EditHandler
+
+
+class FormSubmissionsPanel(EditHandler):
+    template = "wagtailforms/edit_handlers/form_responses_panel.html"
+
+    def bind_to_model(self, model):
+        new = super().bind_to_model(model)
+        if self.heading is None:
+            new.heading = _('{} submissions').format(model.get_verbose_name())
+        return new
+
+    def render(self):
+        Submission = self.model.get_submission_class()
+        submissions = Submission.objects.filter(page=self.instance)
+        submission_count = submissions.count()
+
+        if not submission_count:
+            return ''
+
+        return mark_safe(render_to_string(self.template, {
+            'self': self,
+            'submission_count': submission_count,
+            'last_submit_time': (submissions.order_by('submit_time')
+                                 .last().submit_time),
+        }))

+ 841 - 0
coderedcms/wagtail_flexible_forms/models.py

@@ -0,0 +1,841 @@
+from collections import OrderedDict
+from importlib import import_module
+from itertools import zip_longest
+import json
+import os
+from pathlib import Path
+
+from PIL import Image
+import datetime
+from django.apps import apps
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.humanize.templatetags.humanize import naturaltime
+from django.core.files.storage import default_storage
+from django.core.serializers.json import DjangoJSONEncoder
+from django.db.models import (
+    CharField, TextField, DateTimeField, Model, ForeignKey, PROTECT, CASCADE,
+    QuerySet,
+)
+from django.db.models.fields.files import FieldFile
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+from django.forms import Form, ImageField, FileField, URLField, EmailField
+from django.http import HttpResponseRedirect
+from django.template.response import TemplateResponse
+from django.utils.safestring import SafeData, mark_safe
+from django.utils.timezone import now
+from django.utils.translation import ugettext_lazy as _
+from wagtail.core.models import Page
+from wagtail.contrib.forms.models import (
+    AbstractForm, AbstractEmailForm, AbstractFormSubmission)
+
+from .blocks import FormStepBlock, FormFieldBlock
+
+
+class Step:
+    def __init__(self, steps, index, struct_child):
+        self.steps = steps
+        self.index = index
+        block = getattr(struct_child, 'block', None)
+        if block is None:
+            struct_child = []
+        if isinstance(block, FormStepBlock):
+            self.name = struct_child.value['name']
+            self.form_fields = struct_child.value['form_fields']
+        else:
+            self.name = ''
+            self.form_fields = struct_child
+
+    @property
+    def index1(self):
+        return self.index + 1
+
+    @property
+    def url(self):
+        return '%s?step=%s' % (self.steps.page.url, self.index1)
+
+    def get_form_fields(self):
+        form_fields = OrderedDict()
+        field_blocks = self.form_fields
+        for struct_child in field_blocks:
+            block = struct_child.block
+            if isinstance(block, FormFieldBlock):
+                struct_value = struct_child.value
+                field_name = block.get_slug(struct_value)
+                form_fields[field_name] = block.get_field(struct_value)
+        return form_fields
+
+    def get_form_class(self):
+        return type('WagtailForm', self.steps.page.get_form_class_bases(),
+                    self.get_form_fields())
+
+    def get_markups_and_bound_fields(self, form):
+        for struct_child in self.form_fields:
+            block = struct_child.block
+            if isinstance(block, FormFieldBlock):
+                struct_value = struct_child.value
+                field_name = block.get_slug(struct_value)
+                yield form[field_name], 'field'
+            else:
+                yield mark_safe(struct_child), 'markup'
+
+    def __str__(self):
+        if self.name:
+            return self.name
+        return _('Step %s') % self.index1
+
+    @property
+    def badge(self):
+        return (mark_safe('<span class="badge">%s/%s</span>')
+                % (self.index1, len(self.steps)))
+
+    def __html__(self):
+        return '%s %s' % (self, self.badge)
+
+    @property
+    def is_active(self):
+        return self.index == self.steps.current_index
+
+    @property
+    def is_last(self):
+        return self.index1 == len(self.steps)
+
+    @property
+    def has_prev(self):
+        return self.index > 0
+
+    @property
+    def has_next(self):
+        return self.index1 < len(self.steps)
+
+    @property
+    def prev(self):
+        if self.has_prev:
+            return self.steps[self.index-1]
+
+    @property
+    def next(self):
+        if self.has_next:
+            return self.steps[self.index+1]
+
+    def get_existing_data(self, raw=False):
+        data = self.steps.get_existing_data()[self.index]
+        fields = self.get_form_fields()
+        if not raw:
+            class FakeField:
+                storage = self.steps.get_storage()
+
+            for field_name, value in data.items():
+                if field_name in fields and isinstance(fields[field_name],
+                                                       FileField):
+                    data[field_name] = FieldFile(None, FakeField, value)
+        return data
+
+    @property
+    def is_available(self):
+        return self.prev is None or self.prev.get_existing_data(raw=True)
+
+
+class StreamFormJSONEncoder(DjangoJSONEncoder):
+    def default(self, o):
+        try:
+            from phonenumber_field.phonenumber import PhoneNumber
+        except ImportError:
+            pass
+        else:
+            if isinstance(o, PhoneNumber):
+                return str(o)
+
+        return super().default(o)
+
+
+class Steps(list):
+    def __init__(self, page, request=None):
+        self.page = page
+        # TODO: Make it possible to change the `form_fields` attribute.
+        self.form_fields = page.form_fields
+        self.request = request
+        has_steps = any(isinstance(struct_child.block, FormStepBlock)
+                        for struct_child in self.form_fields)
+        if has_steps:
+            steps = [Step(self, i, form_field)
+                     for i, form_field in enumerate(self.form_fields)]
+        else:
+            steps = [Step(self, 0, self.form_fields)]
+        super().__init__(steps)
+
+    def clamp_index(self, index: int):
+        if index < 0:
+            index = 0
+        if index >= len(self):
+            index = len(self) - 1
+        while not self[index].is_available:
+            index -= 1
+        return index
+
+    @property
+    def current_index(self):
+        return self.request.session.get(self.page.current_step_session_key, 0)
+
+    @property
+    def current(self):
+        return self[self.current_index]
+
+    @current.setter
+    def current(self, new_index: int):
+        if not isinstance(new_index, int):
+            raise TypeError('Use an integer to set the new current step.')
+        self.request.session[self.page.current_step_session_key] = \
+            self.clamp_index(new_index)
+
+    def forward(self, increment: int = 1):
+        self.current = self.current_index + increment
+
+    def backward(self, increment: int = 1):
+        self.current = self.current_index - increment
+
+    def get_submission(self):
+        return self.page.get_submission(self.request)
+
+    def get_existing_data(self):
+        submission = self.get_submission()
+        data = [] if submission is None else json.loads(submission.form_data)
+        length_difference = len(self) - len(data)
+        if length_difference > 0:
+            data.extend([{}] * length_difference)
+        return data
+
+    def get_current_form(self):
+        request = self.request
+        if request.method == 'POST':
+            step_value = request.POST.get('step', 'next')
+            if step_value == 'prev':
+                self.backward()
+            else:
+                return self.current.get_form_class()(
+                    request.POST, request.FILES,
+                    initial=self.current.get_existing_data())
+        return self.current.get_form_class()(
+            initial=self.current.get_existing_data())
+
+    def get_storage(self):
+        return self.page.get_storage()
+
+    def save_files(self, form):
+        submission = self.get_submission()
+        for name, field in form.fields.items():
+            if isinstance(field, FileField):
+                file = form.cleaned_data[name]
+                if file == form.initial.get(name, ''):  # Nothing submitted.
+                    form.cleaned_data[name] = file.name
+                    continue
+                if submission is not None:
+                    submission.delete_file(name)
+                if not file:  # 'Clear' was checked.
+                    form.cleaned_data[name] = ''
+                    continue
+                directory = self.request.session.session_key
+                storage = self.get_storage()
+                Path(storage.path(directory)).mkdir(parents=True,
+                                                    exist_ok=True)
+                path = storage.get_available_name(
+                    str(Path(directory) / file.name))
+                with storage.open(path, 'wb+') as destination:
+                    for chunk in file.chunks():
+                        destination.write(chunk)
+                form.cleaned_data[name] = path
+
+    def update_data(self):
+        form = self.get_current_form()
+        if form.is_valid():
+            form_data = self.get_existing_data()
+            self.save_files(form)
+            form_data[self.current_index] = form.cleaned_data
+            form_data = json.dumps(form_data, cls=StreamFormJSONEncoder)
+            is_complete = self.current.is_last
+            submission = self.get_submission()
+            submission.form_data = form_data
+            if not submission.is_complete and is_complete:
+                submission.status = submission.COMPLETE
+            submission.save()
+            if is_complete:
+                self.current = 0
+            else:
+                self.forward()
+            return is_complete
+        return False
+
+
+class SessionFormSubmission(AbstractFormSubmission):
+
+    session_key = CharField(max_length=40, null=True, default=None)
+    user = ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
+                      related_name='+', on_delete=PROTECT)
+    thumbnails_by_path = TextField(default=json.dumps({}))
+    last_modification = DateTimeField(_('last modification'), auto_now=True)
+    INCOMPLETE = 'incomplete'
+    COMPLETE = 'complete'
+    REVIEWED = 'reviewed'
+    APPROVED = 'approved'
+    REJECTED = 'rejected'
+    STATUSES = (
+        (INCOMPLETE, _('Not submitted')),
+        (COMPLETE, _('In progress')),
+        (REVIEWED, _('Under consideration')),
+        (APPROVED, _('Approved')),
+        (REJECTED, _('Rejected')),
+    )
+    status = CharField(max_length=10, choices=STATUSES, default=INCOMPLETE)
+
+    class Meta:
+        verbose_name = _('form submission')
+        verbose_name_plural = _('form submissions')
+        unique_together = (('page', 'session_key'),
+                           ('page', 'user'))
+        abstract=True
+
+    @property
+    def is_complete(self):
+        return self.status != self.INCOMPLETE
+
+    @property
+    def form_page(self):
+        return self.page.specific
+
+    def get_session(self):
+        return import_module(settings.SESSION_ENGINE).SessionStore(
+            session_key=self.session_key)
+
+    def reset_step(self):
+        session = self.get_session()
+        try:
+            del session[self.form_page.current_step_session_key]
+        except KeyError:
+            pass
+        else:
+            session.save()
+
+    def get_storage(self):
+        return self.form_page.get_storage()
+
+    def get_thumbnail_path(self, path, width=64, height=64):
+        if not path:
+            return ''
+        variant = '%s×%s' % (width, height)
+        thumbnails_by_path = json.loads(self.thumbnails_by_path)
+        thumbnails_paths = thumbnails_by_path.get(path)
+        if thumbnails_paths is None:
+            thumbnails_by_path[path] = {}
+        else:
+            thumbnail_path = thumbnails_paths.get(variant)
+            if thumbnail_path is not None:
+                return thumbnail_path
+
+        path = Path(path)
+        thumbnail_path = str(path.with_suffix('.%s%s'
+                                              % (variant, path.suffix)))
+        storage = self.get_storage()
+        thumbnail_path = storage.get_available_name(thumbnail_path)
+
+        thumbnail = Image.open(storage.path(path))
+        thumbnail.thumbnail((width, height))
+        thumbnail.save(storage.path(thumbnail_path))
+
+        thumbnails_by_path[str(path)][variant] = thumbnail_path
+        self.thumbnails_by_path = json.dumps(thumbnails_by_path,
+                                             cls=StreamFormJSONEncoder)
+        self.save()
+        return thumbnail_path
+
+    def get_fields(self, by_step=False):
+        return self.form_page.get_form_fields(by_step=by_step)
+
+    def get_existing_thumbnails(self, path):
+        thumbnails_paths = json.loads(self.thumbnails_by_path).get(path, {})
+        for thumbnail_path in thumbnails_paths.values():
+            yield thumbnail_path
+
+    def get_files_by_field(self):
+        data = self.get_data(raw=True)
+        files = {}
+        for name, field in self.get_fields().items():
+            if isinstance(field, FileField):
+                path = data.get(name)
+                if path:
+                    files[name] = [path] + list(
+                        self.get_existing_thumbnails(path))
+        return files
+
+    def get_all_files(self):
+        for paths in self.get_files_by_field().values():
+            for path in paths:
+                yield path
+
+    def delete_file(self, field_name):
+        thumbnails_by_path = json.loads(self.thumbnails_by_path)
+        for path in self.get_files_by_field().get(field_name, ()):
+            self.get_storage().delete(path)
+            if path in thumbnails_by_path:
+                del thumbnails_by_path[path]
+        self.thumbnails_by_path = json.dumps(thumbnails_by_path,
+                                             cls=StreamFormJSONEncoder)
+        self.save()
+
+    def render_email(self, value):
+        return (mark_safe('<a href="mailto:%s" target="_blank">%s</a>')
+                % (value, value))
+
+    def render_link(self, value):
+        return (mark_safe('<a href="%s" target="_blank">%s</a>')
+                % (value, value))
+
+    def render_image(self, value):
+        storage = self.get_storage()
+        return (mark_safe('<a href="%s" target="_blank"><img src="%s" /></a>')
+                % (storage.url(value),
+                   storage.url(self.get_thumbnail_path(value))))
+
+    def render_file(self, value):
+        return mark_safe('<a href="%s" target="_blank">%s</a>') % (
+            self.get_storage().url(value),
+            Path(value).name
+        )
+
+    def format_value(self, field, value):
+        if value is None or value == '':
+            return '-'
+        new_value = self.form_page.format_value(field, value)
+        if new_value != value:
+            return new_value
+        if value is True:
+            return 'Yes'
+        if value is False:
+            return 'No'
+        if isinstance(value, (list, tuple)):
+            return ', '.join([self.format_value(field, item)
+                              for item in value])
+        if isinstance(value, datetime.date):
+            return value
+        if isinstance(field, EmailField):
+            return self.render_email(value)
+        if isinstance(field, URLField):
+            return self.render_link(value)
+        if isinstance(field, ImageField):
+            return self.render_image(value)
+        if isinstance(field, FileField):
+            return self.render_file(value)
+        if isinstance(value, SafeData) or hasattr(value, '__html__'):
+            return value
+        return str(value)
+
+    def format_db_field(self, field_name, raw=False):
+        method = getattr(self, 'get_%s_display' % field_name, None)
+        if method is not None:
+            return method()
+        value = getattr(self, field_name)
+        if raw:
+            return value
+        return self.format_value(self._meta.get_field(field_name).formfield(),
+                                 value)
+
+    def get_steps_data(self, raw=False):
+        steps_data = json.loads(self.form_data)
+        if raw:
+            return steps_data
+        fields_and_data_iterator = zip_longest(self.get_fields(by_step=True),
+                                               steps_data, fillvalue={})
+        return [
+            OrderedDict([(name, self.format_value(field, step_data.get(name)))
+                         for name, field in step_fields.items()])
+            for step_fields, step_data in fields_and_data_iterator]
+
+    def get_extra_data(self, raw=False):
+        return self.form_page.get_extra_data(self, raw=raw)
+
+    def get_data(self, raw=False, add_metadata=True):
+        steps_data = self.get_steps_data(raw=raw)
+        form_data = {}
+        form_data.update(self.get_extra_data(raw=raw))
+        for step_data in steps_data:
+            form_data.update(step_data)
+        if add_metadata:
+            form_data.update(
+                status=self.format_db_field('status', raw=raw),
+                user=self.format_db_field('user', raw=raw),
+                submit_time=self.format_db_field('submit_time', raw=raw),
+                last_modification=self.format_db_field('last_modification',
+                                                       raw=raw),
+            )
+        return form_data
+
+    def steps_with_data_iterator(self, raw=False):
+        for step, step_data_fields, step_data in zip(
+                self.form_page.get_steps(),
+                self.form_page.get_data_fields(by_step=True),
+                self.get_steps_data(raw=raw)):
+            yield step, [(field_name, field_label, step_data[field_name])
+                         for field_name, field_label in step_data_fields]
+
+
+@receiver(post_delete, sender=SessionFormSubmission)
+def delete_files(sender, **kwargs):
+    instance = kwargs['instance']
+    instance.reset_step()
+    storage = instance.get_storage()
+    for path in instance.get_all_files():
+        storage.delete(path)
+
+        # Automatically deletes ancestor folders if empty.
+        directory = Path(path)
+        while directory.parent != Path(directory.root):
+            directory = directory.parent
+            try:
+                subdirectories, files = storage.listdir(directory)
+            except FileNotFoundError:
+                continue
+            if not subdirectories and not files:
+                Path(storage.path(directory)).rmdir()
+
+
+class SubmissionRevisionQuerySet(QuerySet):
+    def for_submission(self, submission):
+        return self.filter(**self.model.get_filters_for(submission))
+
+    def created(self):
+        return self.filter(type=self.model.CREATED)
+
+    def changed(self):
+        return self.filter(type=self.model.CHANGED)
+
+    def deleted(self):
+        return self.filter(type=self.model.DELETED)
+
+
+class SubmissionRevision(Model):
+    CREATED = 'created'
+    CHANGED = 'changed'
+    DELETED = 'deleted'
+    TYPES = (
+        (CREATED, _('Created')),
+        (CHANGED, _('Changed')),
+        (DELETED, _('Deleted')),
+    )
+    type = CharField(max_length=7, choices=TYPES)
+    created_at = DateTimeField(auto_now_add=True)
+    submission_ct = ForeignKey('contenttypes.ContentType', on_delete=CASCADE)
+    submission_id = TextField()
+    submission = GenericForeignKey('submission_ct', 'submission_id')
+    data = TextField()
+    summary = TextField()
+
+    objects = SubmissionRevisionQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('-created_at',)
+        abstract=True
+
+    @staticmethod
+    def get_filters_for(submission):
+        return {
+            'submission_ct':
+                ContentType.objects.get_for_model(submission._meta.model),
+            'submission_id': str(submission.pk),
+        }
+
+    @classmethod
+    def diff_summary(cls, page, data1, data2):
+        diff = []
+        data_fields = page.get_data_fields()
+        hidden_types = (tuple, list, dict)
+        for k, label in data_fields:
+            value1 = data1.get(k)
+            value2 = data2.get(k)
+            if value2 == value1 or not value1 and not value2:
+                continue
+            is_hidden = (isinstance(value1, hidden_types)
+                         or isinstance(value2, hidden_types))
+
+            # Escapes newlines as they are used as separator inside summaries.
+            if isinstance(value1, str):
+                value1 = value1.replace('\n', r'\n')
+            if isinstance(value2, str):
+                value2 = value2.replace('\n', r'\n')
+
+            if value2 and not value1:
+                diff.append(
+                    ((_('“%s” set.') % label) if is_hidden
+                     else (_('“%s” set to “%s”.')) % (label, value2)))
+            elif value1 and not value2:
+                diff.append(_('“%s” unset.') % label)
+            else:
+                diff.append(((_('“%s” changed.') % label) if is_hidden
+                             else (_('“%s” changed from “%s” to “%s”.')
+                                   % (label, value1, value2))))
+        return '\n'.join(diff)
+
+    @classmethod
+    def create_from_submission(cls, submission, revision_type):
+        page = submission.form_page
+        try:
+            previous = cls.objects.for_submission(
+                submission).latest('created_at')
+        except cls.DoesNotExist:
+            previous_data = {}
+        else:
+            previous_data = previous.get_data()
+        filters = cls.get_filters_for(submission)
+        data = submission.get_data(raw=True, add_metadata=False)
+        data['status'] = submission.status
+        if revision_type == cls.CREATED:
+            summary = _('Submission created.')
+        elif revision_type == cls.DELETED:
+            summary = _('Submission deleted.')
+        else:
+            summary = cls.diff_summary(page, previous_data, data)
+        if not summary:  # Nothing changed.
+            return
+        filters.update(
+            type=revision_type,
+            data=json.dumps(data, cls=StreamFormJSONEncoder),
+            summary=summary,
+        )
+        return cls.objects.create(**filters)
+
+    def get_data(self):
+        return json.loads(self.data)
+
+# ORIGINAL NORIPYT CODE.
+# We don't want these receivers triggering.
+
+# @receiver(post_save)
+# def create_submission_changed_revision(sender, **kwargs):
+#     if not issubclass(sender, SessionFormSubmission):
+#         return
+#     submission = kwargs['instance']
+#     created = kwargs['created']
+#     SubmissionRevision.create_from_submission(
+#         submission, (SubmissionRevision.CREATED if created
+#                      else SubmissionRevision.CHANGED))
+
+
+# @receiver(post_delete)
+# def create_submission_deleted_revision(sender, **kwargs):
+#     if not issubclass(sender, SessionFormSubmission):
+#         return
+#     submission = kwargs['instance']
+#     SubmissionRevision.create_from_submission(submission,
+#                                               SubmissionRevision.DELETED)
+
+
+class StreamFormMixin:
+    preview_modes = Page.DEFAULT_PREVIEW_MODES
+
+    @property
+    def current_step_session_key(self):
+        return '%s:step' % self.pk
+
+    def get_steps(self, request=None):
+        if not hasattr(self, 'steps'):
+            steps = Steps(self, request=request)
+            if request is None:
+                return steps
+            self.steps = steps
+        return self.steps
+
+    def get_form_fields(self, by_step=False):
+        if by_step:
+            return [step.get_form_fields() for step in self.get_steps()]
+        form_fields = OrderedDict()
+        for step_fields in self.get_form_fields(by_step=True):
+            form_fields.update(step_fields)
+        return form_fields
+
+    def get_context(self, request, *args, **kwargs):
+        context = super().get_context(request, *args, **kwargs)
+        self.steps = self.get_steps(request)
+        step_value = request.GET.get('step')
+        if step_value is not None and step_value.isdigit():
+            self.steps.current = int(step_value) - 1
+        form = self.steps.get_current_form()
+        context.update(
+            steps=self.steps,
+            step=self.steps.current,
+            form=form,
+            markups_and_bound_fields=list(
+                self.steps.current.get_markups_and_bound_fields(form)),
+        )
+        return context
+
+    def get_storage(self):
+        return default_storage
+
+    @staticmethod
+    def get_form_class_bases():
+        return Form,
+
+    @staticmethod
+    def get_submission_class():
+        return SessionFormSubmission
+
+    def get_submission(self, request):
+        Submission = self.get_submission_class()
+        if request.user.is_authenticated:
+            user_submission = Submission.objects.filter(
+                user=request.user, page=self).order_by('-pk').first()
+            if user_submission is None:
+                return Submission(user=request.user, page=self, form_data='[]')
+            return user_submission
+
+        user_submission = Submission.objects.filter(
+            session_key=request.session.session_key, page=self
+        ).order_by('-pk').first()
+        if user_submission is None:
+            return Submission(session_key=request.session.session_key,
+                              page=self, form_data='[]')
+        return user_submission
+
+    def get_success_url(self):
+        form_complete_models = [model for model in apps.get_models()
+                                if issubclass(model, FormCompleteMixin)]
+        cts = (ContentType.objects
+               .get_for_models(*form_complete_models).values())
+        first_child = self.get_children().filter(content_type__in=cts).first()
+        if first_child is None:
+            return self.url
+        return first_child.url
+
+    def serve_success(self, request, *args, **kwargs):
+        url = self.get_success_url()
+        if url == self.url:
+            messages.success(request,
+                             _('Successfully submitted the form.'))
+        return HttpResponseRedirect(url)
+
+    def serve(self, request, *args, **kwargs):
+        context = self.get_context(request)
+        form = context['form']
+        if request.method == 'POST' and form.is_valid():
+            is_complete = self.steps.update_data()
+            if is_complete:
+                return self.serve_success(request, *args, **kwargs)
+            return HttpResponseRedirect(self.url)
+        return Page.serve(self, request, *args, **kwargs)
+
+    def get_data_fields(self, by_step=False, add_metadata=True):
+        if by_step:
+            return [[(field_name, field.label)
+                     for field_name, field in step_fields.items()]
+                    for step_fields in self.get_form_fields(by_step=True)]
+
+        data_fields = []
+        data_fields.extend(self.get_extra_data_fields())
+        if add_metadata:
+            data_fields.extend((
+                ('status', _('Status')),
+                ('user', _('User')),
+                ('submit_time', _('First modification')),
+                ('last_modification', _('Last modification'))))
+        data_fields.extend([
+            (field_name, field_label)
+            for step_data_fields in self.get_data_fields(by_step=True)
+            for field_name, field_label in step_data_fields])
+        return data_fields
+
+    def get_extra_data_fields(self):
+        return ()
+
+    def get_extra_data(self, submission, raw=False):
+        return {}
+
+    def format_value(self, field, value):
+        return value
+
+
+class ClosingFormMixin(Model):
+    closing_at = DateTimeField()
+
+    closed_template = None
+
+    class Meta:
+        abstract = True
+
+    @property
+    def is_closed(self):
+        return now() > self.closing_at
+
+    def get_closed_template(self, request, *args, **kwargs):
+        if self.closed_template is None:
+            template = self.get_template(request, *args, **kwargs)
+            base, ext = os.path.splitext(template)
+            return '%s_closed%s' % (base, ext)
+        return self.closed_template
+
+    def serve_closed(self, request, *args, **kwargs):
+        return TemplateResponse(
+            request,
+            self.get_closed_template(request, *args, **kwargs),
+            self.get_context(request, *args, **kwargs),
+        )
+
+    def serve(self, request, *args, **kwargs):
+        if self.is_closed:
+            return self.serve_closed(request, *args, **kwargs)
+        return super().serve(request, *args, **kwargs)
+
+
+class FormCompleteMixin:
+    def get_form_page(self):
+        return self.get_parent().specific
+
+    def serve(self, request, *args, **kwargs):
+        form_page = self.get_form_page()
+        if isinstance(form_page, LoginRequiredMixin) \
+                and not request.user.is_authenticated():
+            return HttpResponseRedirect(form_page.url)
+        self.submission = form_page.get_submission(request)
+        if self.submission is not None and self.submission.is_complete \
+                or getattr(request, 'is_preview', False):
+            return super().serve(request, *args, **kwargs)
+        return HttpResponseRedirect(form_page.url)
+
+    def get_context(self, *args, **kwargs):
+        context = super().get_context(*args, **kwargs)
+        if hasattr(self, 'submission'):
+            context['submission'] = self.submission
+        return context
+
+
+class LoginRequiredMixin:
+    login_required_template = None
+
+    def get_login_required_template(self, request, *args, **kwargs):
+        if self.login_required_template is None:
+            template = self.get_template(request, *args, **kwargs)
+            base, ext = os.path.splitext(template)
+            return '%s_login_required%s' % (base, ext)
+        return self.login_required_template
+
+    def serve_login_required(self, request, *args, **kwargs):
+        return TemplateResponse(
+            request,
+            self.get_login_required_template(request, *args, **kwargs),
+            self.get_context(request, *args, **kwargs),
+        )
+
+    def serve(self, request, *args, **kwargs):
+        if not request.user.is_authenticated():
+            return self.serve_login_required(request, *args, **kwargs)
+        return super().serve(request, *args, **kwargs)
+
+
+class AbstractStreamForm(StreamFormMixin, AbstractForm):
+    class Meta:
+        abstract = True
+
+
+class AbstractEmailStreamForm(StreamFormMixin, AbstractEmailForm):
+    class Meta:
+        abstract = True

+ 320 - 0
coderedcms/wagtail_flexible_forms/wagtail_hooks.py

@@ -0,0 +1,320 @@
+from django.conf.urls import url
+from django.contrib.admin import SimpleListFilter
+from django.contrib.admin.utils import quote
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils.translation import ugettext_lazy as _
+from wagtail.contrib.modeladmin.helpers import (
+    PermissionHelper, PagePermissionHelper, PageAdminURLHelper, AdminURLHelper,
+    ButtonHelper)
+from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
+from wagtail.contrib.modeladmin.views import IndexView, InstanceSpecificView
+from wagtail.admin import messages
+from wagtail.core import hooks
+from wagtail.core.models import Page
+from wagtail.contrib.forms.utils import get_forms_for_user
+
+from .models import SessionFormSubmission
+
+
+class FormIndexView(IndexView):
+    page_title = _('Forms')
+
+
+class FormPermissionHelper(PagePermissionHelper):
+    def user_can_list(self, user):
+        return get_forms_for_user(user).exists()
+
+    def user_can_create(self, user):
+        return False
+
+    def user_can_edit_obj(self, user, obj):
+        return False
+
+    def user_can_delete_obj(self, user, obj):
+        return False
+
+    def user_can_publish_obj(self, user, obj):
+        return False
+
+    def user_can_unpublish_obj(self, user, obj):
+        return False
+
+    def user_can_copy_obj(self, user, obj):
+        return False
+
+    def user_can_inspect_obj(self, user, obj):
+        return False
+
+
+class FormURLHelper(PageAdminURLHelper):
+    def _get_action_url_pattern(self, action):
+        if action == 'index':
+            return r'^stream_forms/$'
+        return r'^stream_forms/%s/$' % action
+
+
+class FormAdmin(ModelAdmin):
+    model = Page
+    menu_label = _('Forms')
+    menu_icon = 'form'
+    list_display = ('title', 'unprocessed_submissions_link',
+                    'all_submissions_link', 'edit_link')
+    index_view_class = FormIndexView
+    permission_helper_class = FormPermissionHelper
+    url_helper_class = FormURLHelper
+
+    def get_queryset(self, request):
+        return get_forms_for_user(request.user)
+
+    def all_submissions_link(self, obj, label=_('See all submissions'),
+                             url_suffix=''):
+        return '<a href="%s?page_id=%s%s">%s</a>' % (
+            reverse(SubmissionAdmin().url_helper.get_action_url_name('index')),
+            obj.pk, url_suffix, label)
+    all_submissions_link.short_description = ''
+    all_submissions_link.allow_tags = True
+
+    def unprocessed_submissions_link(self, obj):
+        return self.all_submissions_link(
+            obj, _('See unprocessed submissions'),
+            '&status=%s' % SubmissionStatusFilter.unprocessed_status)
+    unprocessed_submissions_link.short_description = ''
+    unprocessed_submissions_link.allow_tags = True
+
+    def edit_link(self, obj):
+        return '<a href="%s">%s</a>' % (
+            reverse('wagtailadmin_pages:edit', args=(obj.pk,)),
+            _('Edit this form page'))
+    edit_link.short_description = ''
+    edit_link.allow_tags = True
+
+
+class SubmissionStatusFilter(SimpleListFilter):
+    title = _('status')
+    parameter_name = 'status'
+    unprocessed_status = ','.join((SessionFormSubmission.COMPLETE,
+                                   SessionFormSubmission.REVIEWED))
+
+    def lookups(self, request, model_admin):
+        yield (self.unprocessed_status, _('Complete or reviewed'))
+        for status, verbose_status in SessionFormSubmission.STATUSES:
+            if status != SessionFormSubmission.INCOMPLETE:
+                yield status, verbose_status
+
+    def queryset(self, request, queryset):
+        status = self.value()
+        if not status:
+            return queryset
+        if ',' in status:
+            return queryset.filter(status__in=status.split(','))
+        return queryset.filter(status=status)
+
+
+class SubmissionPermissionHelper(PermissionHelper):
+    def user_can_list(self, user):
+        return get_forms_for_user(user).exists()
+
+    def user_can_create(self, user):
+        return False
+
+    def user_can_edit_obj(self, user, obj):
+        return False
+
+    def user_can_inspect_obj(self, user, obj):
+        return False
+
+    def user_can_set_status_obj(self, user, obj):
+        return user.can_set_status()
+
+
+class SubmissionURLHelper(AdminURLHelper):
+    def _get_action_url_pattern(self, action):
+        if action == 'index':
+            return r'^%s/%s/$' % (self.opts.app_label, 'submissions')
+        return r'^%s/%s/%s/$' % (self.opts.app_label, 'submissions', action)
+
+    def _get_object_specific_action_url_pattern(self, action):
+        return r'^%s/%s/%s/(?P<instance_pk>[-\w]+)/$' % (
+            self.opts.app_label, 'submissions', action)
+
+
+class SubmissionButtonHelper(ButtonHelper):
+    def set_status_button(self, pk, status, label, title, classnames_add=None,
+                          classnames_exclude=None):
+        if classnames_add is None:
+            classnames_add = []
+        if classnames_exclude is None:
+            classnames_exclude = []
+        classnames = self.finalise_classname(classnames_add,
+                                             classnames_exclude)
+        url = self.url_helper.get_action_url('set_status', quote(pk))
+        url += '?status=' + status
+        return {
+            'url': url,
+            'label': label,
+            'classname': classnames,
+            'title': title,
+        }
+
+    def reviewed_button(self, pk, classnames_add=None,
+                        classnames_exclude=None):
+        if classnames_add is None:
+            classnames_add = []
+        return self.set_status_button(pk, self.model.REVIEWED,
+                                      _('mark as reviewed'),
+                                      _('Mark this submission as reviewed'),
+                                      classnames_add=classnames_add,
+                                      classnames_exclude=classnames_exclude)
+
+    def approve_button(self, pk, classnames_add=None,
+                        classnames_exclude=None):
+        if classnames_add is None:
+            classnames_add = []
+        if 'button-secondary' in classnames_add:
+            classnames_add.remove('button-secondary')
+        classnames_add = ['yes'] + classnames_add
+        return self.set_status_button(pk, self.model.APPROVED, _('approve'),
+                                      _('Approve this submission'),
+                                      classnames_add=classnames_add,
+                                      classnames_exclude=classnames_exclude)
+
+    def reject_button(self, pk, classnames_add=None,
+                      classnames_exclude=None):
+        if classnames_add is None:
+            classnames_add = []
+        if 'button-secondary' in classnames_add:
+            classnames_add.remove('button-secondary')
+        classnames_add = ['no'] + classnames_add
+        return self.set_status_button(pk, self.model.REJECTED, _('reject'),
+                                      _('Reject this submission'),
+                                      classnames_add=classnames_add,
+                                      classnames_exclude=classnames_exclude)
+
+    def get_buttons_for_obj(self, obj, exclude=None, classnames_add=None,
+                            classnames_exclude=None):
+        buttons = super().get_buttons_for_obj(
+            obj, exclude=exclude, classnames_add=classnames_add,
+            classnames_exclude=classnames_exclude)
+        pk = getattr(obj, self.opts.pk.attname)
+        status_buttons = []
+        if obj.status != obj.REVIEWED:
+            status_buttons.append(self.reviewed_button(
+                pk, classnames_add=classnames_add,
+                classnames_exclude=classnames_exclude))
+        if obj.status != obj.APPROVED:
+            status_buttons.append(self.approve_button(
+                pk, classnames_add=classnames_add,
+                classnames_exclude=classnames_exclude))
+        if obj.status != obj.REJECTED:
+            status_buttons.append(self.reject_button(
+                pk, classnames_add=classnames_add,
+                classnames_exclude=classnames_exclude))
+        return status_buttons + buttons
+
+
+class SetStatusView(InstanceSpecificView):
+    def check_action_permitted(self, user):
+        return self.permission_helper.user_can_set_status_obj(user,
+                                                              self.instance)
+
+    def get(self, request, *args, **kwargs):
+        status = request.GET.get('status')
+        if status in dict(self.model.STATUSES):
+            previous_status = self.instance.status
+            self.instance.status = status
+            self.instance.save()
+            verbose_label = self.instance.get_status_display()
+            if 'revert' in request.GET:
+                messages.success(request, 'Reverted to the “%s” status.'
+                                 % verbose_label)
+            else:
+                revert_url = (self.url_helper.get_action_url('set_status',
+                                                             self.instance_pk)
+                              + '?revert&status=' + previous_status)
+                messages.success(
+                    request,
+                    'Successfully changed the status to “%s”.' % verbose_label,
+                    buttons=[messages.button(revert_url, _('Revert'))])
+        url = request.META.get('HTTP_REFERER')
+        if url is None:
+            url = (self.url_helper.get_action_url('index')
+                   + '?page_id=%s' % self.instance.page_id)
+        return redirect(url)
+
+
+class SubmissionAdmin(ModelAdmin):
+    model = SessionFormSubmission
+    menu_icon = 'form'
+    permission_helper_class = SubmissionPermissionHelper
+    url_helper_class = SubmissionURLHelper
+    button_helper_class = SubmissionButtonHelper
+    set_status_view_class = SetStatusView
+    list_display = ('status', 'user', 'submit_time', 'last_modification')
+    list_filter = (SubmissionStatusFilter, 'submit_time', 'last_modification')
+    search_fields = ('user__first_name', 'user__last_name')
+
+    def register_with_wagtail(self):
+        @hooks.register('register_permissions')
+        def register_permissions():
+            return self.get_permissions_for_registration()
+
+        @hooks.register('register_admin_urls')
+        def register_admin_urls():
+            return self.get_admin_urls_for_registration()
+
+    def get_queryset(self, request):
+        qs = super().get_queryset(request)
+        form_pages = get_forms_for_user(request.user)
+        return (qs.filter(page__in=form_pages)
+                .exclude(status=self.model.INCOMPLETE))
+
+    def get_form_page(self, request):
+        form_pages = get_forms_for_user(request.user)
+        try:
+            return form_pages.get(pk=int(request.GET['page_id'])).specific
+        except (KeyError, TypeError, ValueError, Page.DoesNotExist):
+            pass
+
+    # TODO: Find a cleaner way to display data from dynamic fields.
+    def add_data_bridge(self, name, label):
+        def data_bridge(obj):
+            return obj.get_data().get(name)
+
+        data_bridge.short_description = label
+        setattr(self, name, data_bridge)
+
+    def get_list_display(self, request):
+        form_page = self.get_form_page(request)
+        if form_page is None:
+            return self.list_display
+        l = []
+        for name, label in form_page.get_data_fields():
+            l.append(name)
+            self.add_data_bridge(name, label)
+        return l
+
+    def set_status_view(self, request, instance_pk):
+        kwargs = {'model_admin': self, 'instance_pk': instance_pk}
+        view_class = self.set_status_view_class
+        return view_class.as_view(**kwargs)(request)
+
+    def get_admin_urls_for_registration(self):
+        urls = super().get_admin_urls_for_registration()
+        urls += (
+            url(self.url_helper.get_action_url_pattern('set_status'),
+                self.set_status_view,
+                name=self.url_helper.get_action_url_name('set_status')),
+        )
+        return urls
+
+
+# @hooks.register('construct_main_menu')
+# def hide_old_forms_module(request, menu_items):
+#     from wagtail.contrib.forms.wagtail_hooks import FormsMenuItem
+#     for menu_item in menu_items:
+#         if isinstance(menu_item, FormsMenuItem):
+#             menu_items.remove(menu_item)
+
+# modeladmin_register(FormAdmin)
+# modeladmin_register(SubmissionAdmin)

+ 47 - 4
coderedcms/wagtail_hooks.py

@@ -3,15 +3,17 @@ import mimetypes
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.staticfiles.templatetags.staticfiles import static
 from django.contrib.staticfiles.templatetags.staticfiles import static
 from django.http.response import HttpResponse
 from django.http.response import HttpResponse
-from django.utils.html import format_html
+from django.urls import reverse
+from django.utils.html import format_html, mark_safe
+from django.utils.translation import ugettext_lazy as _
 from wagtail.contrib.forms.models import AbstractForm
 from wagtail.contrib.forms.models import AbstractForm
+from wagtail.contrib.modeladmin.options import modeladmin_register
 from wagtail.core import hooks
 from wagtail.core import hooks
 from wagtail.core.models import UserPagePermissionsProxy, get_page_models
 from wagtail.core.models import UserPagePermissionsProxy, get_page_models
 from wagtailcache.cache import clear_cache
 from wagtailcache.cache import clear_cache
 
 
 from coderedcms import utils
 from coderedcms import utils
-from coderedcms.models import CoderedFormPage
-
+from coderedcms.wagtail_flexible_forms.wagtail_hooks import FormAdmin, SubmissionAdmin
 
 
 @hooks.register('insert_global_admin_css')
 @hooks.register('insert_global_admin_css')
 def global_admin_css():
 def global_admin_css():
@@ -37,6 +39,7 @@ def clear_wagtailcache(request, page):
 
 
 @hooks.register('filter_form_submissions_for_user')
 @hooks.register('filter_form_submissions_for_user')
 def codered_forms(user, editable_forms):
 def codered_forms(user, editable_forms):
+    from coderedcms.models import CoderedFormMixin
     """
     """
     Add our own CoderedFormPage to editable_forms, since wagtail is unaware
     Add our own CoderedFormPage to editable_forms, since wagtail is unaware
     of its existance. Essentailly this is a fork of wagtail.contrib.forms.get_forms_for_user()
     of its existance. Essentailly this is a fork of wagtail.contrib.forms.get_forms_for_user()
@@ -44,7 +47,7 @@ def codered_forms(user, editable_forms):
     """
     """
     form_models = [
     form_models = [
         model for model in get_page_models()
         model for model in get_page_models()
-        if issubclass(model, (AbstractForm, CoderedFormPage))
+        if issubclass(model, CoderedFormMixin)
     ]
     ]
     form_types = list(
     form_types = list(
         ContentType.objects.get_for_models(*form_models).values()
         ContentType.objects.get_for_models(*form_models).values()
@@ -65,3 +68,43 @@ def serve_document_directly(document, request):
     response['Content-Disposition'] = 'inline;filename="{0}"'.format(document.filename)
     response['Content-Disposition'] = 'inline;filename="{0}"'.format(document.filename)
     response['Content-Encoding'] = content_encoding
     response['Content-Encoding'] = content_encoding
     return response
     return response
+
+
+class CoderedSubmissionAdmin(SubmissionAdmin):
+
+    def __init__(self, parent=None):
+        from coderedcms.models import CoderedSessionFormSubmission
+        self.model = CoderedSessionFormSubmission
+        super().__init__(parent=parent)
+
+
+class CoderedFormAdmin(FormAdmin):
+    list_display = ('title', 'action_links')
+
+    def all_submissions_link(self, obj, label=_('See all submissions'),
+                             url_suffix=''):
+        return '<a href="%s?page_id=%s%s">%s</a>' % (
+            reverse(CoderedSubmissionAdmin().url_helper.get_action_url_name('index')),
+            obj.pk, url_suffix, label)
+    all_submissions_link.short_description = ''
+    all_submissions_link.allow_tags = True
+
+    def action_links(self, obj):
+        from coderedcms.models import CoderedFormPage, CoderedStreamFormPage
+        actions = []
+        if issubclass(type(obj.specific), CoderedFormPage):
+            actions.append(
+                '<a href="{0}">{1}</a>'.format(reverse('wagtailforms:list_submissions', args=(obj.pk,)), _('See all Submissions'))
+            )
+            actions.append(
+                '<a href="{0}">{1}</a>'.format(reverse('wagtailadmin_pages:edit', args=(obj.pk,)), _('Edit this form page'))
+            )
+        elif issubclass(type(obj.specific), CoderedStreamFormPage):
+            actions.append(self.unprocessed_submissions_link(obj))
+            actions.append(self.all_submissions_link(obj))
+            actions.append(self.edit_link(obj))
+
+        return mark_safe("<br />".join(actions))
+
+# modeladmin_register(CoderedFormAdmin)
+# modeladmin_register(CoderedSubmissionAdmin)

+ 1 - 3
docs/_static/docs.css

@@ -24,19 +24,17 @@ div.body h5,
 div.body h6 {
 div.body h6 {
     border: none;
     border: none;
     font-weight: 600;
     font-weight: 600;
-    margin: unset;
+    margin: 1.5em 0 0 0;
     padding: 0;
     padding: 0;
 }
 }
 div.body h1 {
 div.body h1 {
     font-weight: 300;
     font-weight: 300;
     font-size: 2.5em;
     font-size: 2.5em;
-    padding-top: 1em;
     margin-bottom: 1em;
     margin-bottom: 1em;
     line-height: 1;
     line-height: 1;
 }
 }
 div.body h2 {
 div.body h2 {
     font-size: 1.5em;
     font-size: 1.5em;
-    margin-top: 1.5em;
 }
 }
 div.body h3 {
 div.body h3 {
     font-size: 1.2em;
     font-size: 1.2em;

+ 1 - 1
docs/conf.py

@@ -23,7 +23,7 @@ from coderedcms import __shortversion__
 # -- Project information -----------------------------------------------------
 # -- Project information -----------------------------------------------------
 
 
 project = 'CodeRed CMS'
 project = 'CodeRed CMS'
-copyright = str(datetime.datetime.now().year) + ', CodeRed LLC'
+copyright = '2018-' + str(datetime.datetime.now().year) + ', CodeRed LLC'
 author = 'CodeRed LLC'
 author = 'CodeRed LLC'
 
 
 # The short X.Y version
 # The short X.Y version

+ 2 - 2
docs/features/page_types/event_pages.rst

@@ -28,8 +28,8 @@ Content Tab
 * **Address**: The address for the event, if applicable.
 * **Address**: The address for the event, if applicable.
 * **Occurrences**: This lets you add the date and time information for your event.  Click the **+** icon to add a new date and time rule.
 * **Occurrences**: This lets you add the date and time information for your event.  Click the **+** icon to add a new date and time rule.
 
 
-Developer Implementation
-------------------------
+Implementation
+--------------
 
 
 The event functionality is built-in to CodeRed CMS but it is not enabled by default.
 The event functionality is built-in to CodeRed CMS but it is not enabled by default.
 
 

+ 4 - 4
docs/features/page_types/form_pages.rst

@@ -8,8 +8,8 @@ Usage
 
 
 First start by creating a "Form" (may be named differently on your specific website). Add content to this page as you would for a normal Web Page. 
 First start by creating a "Form" (may be named differently on your specific website). Add content to this page as you would for a normal Web Page. 
 
 
-Content Tab Options
-~~~~~~~~~~~~~~~~~~~
+Content Tab
+~~~~~~~~~~~
 
 
 * **Form Fields**: The data you want to collect on the form.  You can add as many fields as required ranging from all form input types like text, files, radio buttons, etc.
 * **Form Fields**: The data you want to collect on the form.  You can add as many fields as required ranging from all form input types like text, files, radio buttons, etc.
 
 
@@ -34,8 +34,8 @@ Content Tab Options
 
 
 Click the "Add Confirmation Emails" button to add additional emails you want to sent out when a form is submitted.
 Click the "Add Confirmation Emails" button to add additional emails you want to sent out when a form is submitted.
 
 
-Settings Tab Options
-~~~~~~~~~~~~~~~~~~~~
+Settings Tab
+~~~~~~~~~~~~
 
 
 * **Form go live date/time**: The optional date/time the form will start appearing on the page.
 * **Form go live date/time**: The optional date/time the form will start appearing on the page.
 * **Form expiry date/time**: The optional date/time the form will stop appearing on the page.
 * **Form expiry date/time**: The optional date/time the form will stop appearing on the page.

+ 1 - 0
docs/features/page_types/index.rst

@@ -8,6 +8,7 @@ Page Types
     event_pages
     event_pages
     form_pages
     form_pages
     location_pages
     location_pages
+    stream_forms
     web_pages
     web_pages
 
 
 
 

+ 83 - 0
docs/features/page_types/stream_forms.rst

@@ -0,0 +1,83 @@
+Stream Forms
+============
+
+CodeRed CMS integrates with ``wagtail_flexible_forms`` (https://github.com/noripyt/wagtail-flexible-forms).
+A Stream Form enables forms built from StreamFields for advanced functionality such as multi-step forms,
+mixed content and form fields, and conditional logic.
+
+.. note::
+    Underlying functionality of Stream Forms may change in future versions as ``wagtail_flexible_forms``
+    is planned to be merged directly into Wagtail. We recommend using the simpler ``CoderedFormPage``
+    for forms that do not require advanced functionality (such as contact forms, etc.).
+
+
+Usage
+-----
+
+First start by creating a "Stream Form" (may be named differently on your specific website).
+Add content to this page as you would for a normal Form. By and large the editing experience
+is similar between a Form and a Stream Form.
+
+
+Conditional Logic
+~~~~~~~~~~~~~~~~~
+
+To enable conditional logic, click **Advanced Settings** and enter a "Custom ID" on a field.
+Then on a second field, enter that same ID for "Condition Trigger ID" and a desired value for
+"Condition Trigger Value". The second field will only then show if the trigger value is selected
+in the first field. For example:
+
+.. code-block:: text
+
+    Field One (checkbox field):
+      Custom ID: swallows-or-coconuts
+
+      [ ] African Swallow
+      [x] Coconut
+
+
+    Field Two (text field):
+      Condition Trigger ID: swallows-or-coconuts
+      Condition Trigger Value: Coconut
+
+In this scenario, Field Two will be shown because the user selected "Coconut" in Field One
+(identified by: "swallows-or-coconuts"). If the user unchecks "Coconut", Field Two will
+then be hidden.
+
+.. note::
+    As of version 0.15, fields with a Condition Trigger ID should NOT be marked required.
+
+
+Content Tab
+~~~~~~~~~~~
+
+**Form Fields**: This field is a bit different from its normal Form counterpart.
+Instead of the normal Form's form field process, form fields are generated via a StreamField.
+This is nice because it allows you to mix content into your form, in between form elements.
+At the top level of the StreamField, you are able to create a step block. Each step block represents
+a piece of the form that will be loaded one at a time. In each step block is an additional
+StreamField that contains a mix of form fields and content blocks.
+
+
+Implementation
+--------------
+
+The stream form functionality is built-in to CodeRed CMS but is not enabled by default.
+To implement, add the following to your ``website/models.py``
+
+.. code-block:: python
+
+    from coderedcms.models import CoderedEmail, CoderedStreamFormPage
+
+    class StreamFormPage(CoderedStreamFormPage):
+        class Meta:
+            verbose_name = 'Stream Form'
+
+        template = 'coderedcms/pages/stream_form_page.html'
+
+    class StreamFormConfirmEmail(CoderedEmail):
+        page = ParentalKey('StreamFormPage', related_name='confirmation_emails')
+
+
+Next run ``python manage.py makemigrations website`` and ``python manage.py migrate`` to create
+the new pages in your project.

+ 1 - 1
docs/features/snippets/index.rst

@@ -11,4 +11,4 @@ Snippets
     navigation_bars
     navigation_bars
     reusable_content
     reusable_content
 
 
-CodeRed CMS follows the Wagtail philosophy when it comes to snippets (viewable here<https://docs.wagtail.io/en/latest/topics/snippets.html>). 
+CodeRed CMS follows the Wagtail philosophy when it comes to snippets (viewable `here <https://docs.wagtail.io/en/latest/topics/snippets.html>`_). 

部分文件因为文件数量过多而无法显示