Jelajahi Sumber

New stream forms (#171)

Fixes #95
Cory Sutyak 5 tahun lalu
induk
melakukan
dac220550e
29 mengubah file dengan 2409 tambahan dan 178 penghapusan
  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 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 .html_blocks import * #noqa
 from .metadata_blocks import * #noqa
@@ -13,6 +17,7 @@ from .content_blocks import * #noqa
 from .layout_blocks import * #noqa
 
 
+
 # Collections of blocks commonly used together.
 
 HTML_STREAMBLOCKS = [
@@ -20,7 +25,7 @@ HTML_STREAMBLOCKS = [
     ('button', ButtonBlock()),
     ('image', ImageBlock()),
     ('image_link', ImageLinkBlock()),
-    ('html', blocks.RawHTMLBlock(icon='code', classname='monospace', label=_('HTML'))),
+    ('html', blocks.RawHTMLBlock(icon='code', classname='monospace', label=_('HTML'), )),
     ('download', DownloadBlock()),
     ('embed_video', EmbedVideoBlock()),
     ('quote', QuoteBlock()),
@@ -64,3 +69,22 @@ LAYOUT_STREAMBLOCKS = [
     ),
     ('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):
+
+    
     def get_csv_response(self, context):
         filename = self.get_csv_filename()
         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.validators import MaxValueValidator, MinValueValidator
 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.template import Context, Template
 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 modelcluster.fields import ParentalKey, ParentalManyToManyField
 from modelcluster.tags import ClusterTaggableManager
+from pathlib import Path
 from taggit.models import TaggedItemBase
 from wagtail.admin.edit_handlers import (
     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.forms import WagtailAdminFormPageForm
 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 wagtailcache.cache import WagtailCacheMixin
 
@@ -53,13 +56,17 @@ from coderedcms import schema, utils
 from coderedcms.blocks import (
     CONTENT_STREAMBLOCKS,
     LAYOUT_STREAMBLOCKS,
+    STREAMFORM_BLOCKS,
     ContentWallBlock,
     OpenHoursBlock,
-    StructuredDataActionBlock)
+    StructuredDataActionBlock,
+    CoderedStreamFormStepBlock)
 from coderedcms.fields import ColorField
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
 from coderedcms.models.snippet_models import ClassifierTerm
 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.widgets import ClassifierSelectWidget
 
@@ -451,7 +458,6 @@ class CoderedPage(WagtailCacheMixin, Page, metaclass=CoderedPageMeta):
         to enable customization by subclasses.
         """
         super().__init__(*args, **kwargs)
-
         klassname = self.__class__.__name__.lower()
         template_choices = cr_settings['FRONTEND_TEMPLATES_PAGES'].get('*', ()) + \
                            cr_settings['FRONTEND_TEMPLATES_PAGES'].get(klassname, ())
@@ -995,26 +1001,15 @@ class CoderedEventOccurrence(Orderable, BaseOccurrence):
         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
+    encoder = DjangoJSONEncoder
 
     ### Custom codered fields
-
     to_address = models.CharField(
         max_length=255,
         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.'),
     )
 
-    body_content_panels = CoderedWebPage.body_content_panels + [
-        FormSubmissionsPanel(),
-        InlinePanel('form_fields', label="Form fields"),
+
+    body_content_panels = [
         MultiFieldPanel(
             [
                 PageChooserPanel('thank_you_page'),
@@ -1120,10 +1114,9 @@ class CoderedFormPage(CoderedWebPage):
             ],
             _('Form Submissions')
         ),
-        InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
     ]
 
-    settings_panels = CoderedPage.settings_panels + [
+    settings_panels = [
         MultiFieldPanel(
             [
                 FieldRowPanel(
@@ -1147,92 +1140,40 @@ class CoderedFormPage(CoderedWebPage):
         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())
 
-
-    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):
         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 = {}
-
         # Handle file uploads
         for key, val in form.cleaned_data.items():
+
             if type(val) == InMemoryUploadedFile or type(val) == TemporaryUploadedFile:
                 # 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:
                 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
         if self.save_to_database:
@@ -1244,7 +1185,7 @@ class CoderedFormPage(CoderedWebPage):
 
         if self.confirmation_emails:
             # 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.
             for email in self.confirmation_emails.all():
 
@@ -1291,24 +1232,20 @@ class CoderedFormPage(CoderedWebPage):
         for fn in hooks.get_hooks('form_page_submit'):
             fn(instance=self, form_submission=form_submission)
 
-        return form_submission
-
     def send_summary_mail(self, request, form, processed_data):
         """
         Sends a form submission summary email.
         """
         addresses = [x.strip() for x in self.to_address.split(',')]
         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(
-                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
         message_args = {
@@ -1324,7 +1261,7 @@ class CoderedFormPage(CoderedWebPage):
             message_args['from_email'] = genemail
         if self.reply_address:
             # 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)
             message_args['reply_to'] = template_reply_to.render(context).split(',')
 
@@ -1332,21 +1269,8 @@ class CoderedFormPage(CoderedWebPage):
         message = EmailMessage(**message_args)
         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):
+
         """
         Renders the landing page.
 
@@ -1365,22 +1289,139 @@ class CoderedFormPage(CoderedWebPage):
         )
         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):
         """
         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.
         """
         view = self.submissions_list_view_class.as_view()
         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):
         if request.method == 'POST':
             form = self.get_form(request.POST, request.FILES, page=self, user=request.user)
 
             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)
         else:
             form = self.get_form(page=self, user=request.user)
@@ -1394,18 +1435,189 @@ class CoderedFormPage(CoderedWebPage):
         )
         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):
     """

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

@@ -9,21 +9,23 @@ from coderedcms.models.page_models import (
     CoderedEventIndexPage,
     CoderedEventPage,
     CoderedFormPage,
-    CoderedPage,
-    CoderedWebPage,
     CoderedLocationIndexPage,
     CoderedLocationPage,
+    CoderedPage,
+    CoderedStreamFormPage,
+    CoderedWebPage,
     get_page_models
 )
 from coderedcms.tests.testapp.models import (
-    ArticlePage,
     ArticleIndexPage,
-    FormPage,
-    WebPage,
-    EventPage,
+    ArticlePage,
     EventIndexPage,
+    EventPage,
+    FormPage,
+    LocationIndexPage,
     LocationPage,
-    LocationIndexPage
+    StreamFormPage,
+    WebPage
 )
 
 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.
         """
         request = self.request_factory.get(self.basic_page.url)
+        request.session = self.client.session
         request.user = AnonymousUser()
         request.site = Site.objects.all()[0]
         response = self.basic_page.serve(request)
@@ -85,6 +88,21 @@ class ConcreteBasicPageTestCase(ConcretePageTestCase, BasicPageTestCase):
     class Meta:
         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):
     model = CoderedArticleIndexPage
 
@@ -125,6 +143,10 @@ class CoderedEventPageTestCase(AbstractPageTestCase, WagtailPageTests):
     model = CoderedEventPage
 
 
+class CoderedStreamFormPageTestCase(AbstractPageTestCase, WagtailPageTests):
+    model = CoderedStreamFormPage
+
+
 class ArticlePageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     model = ArticlePage
 
@@ -133,19 +155,9 @@ class ArticleIndexPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     model = ArticleIndexPage
 
 
-class FormPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
+class FormPageTestCase(ConcreteFormPageTestCase, WagtailPageTests):
     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):
     model = WebPage
@@ -165,3 +177,7 @@ class LocationIndexPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
 
 class LocationPageTestCase(ConcreteBasicPageTestCase, WagtailPageTests):
     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;
 }
 
+.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 */
 

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

@@ -46,6 +46,10 @@ libs = {
     coderedmaps: {
         url: "/static/coderedcms/js/codered-maps.js",
         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-preview').on('click', function(event) {
         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 %}
     <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>
         {% if self.author %}<footer class="blockquote-footer">{{self.author}}</footer>{% endif %}
     </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 random
+from html import unescape
+
+
 from datetime import datetime
 from django import template
 from django.conf import settings
 from django.forms import ClearableFileInput
 from django.utils import timezone
 from django.utils.html import mark_safe
-from django.utils.formats import localize
 from wagtail.core.models import Collection
 from wagtail.core.rich_text 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)
     if utils.uri_validator(str(cell)):
         return mark_safe("<a href='{0}'>{1}</a>".format(cell, cell))
-    return localize(cell)
+    return cell
 
 @register.filter
 def codered_settings(value):

File diff ditekan karena terlalu besar
+ 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,
     CoderedEmail,
     CoderedFormPage,
-    CoderedWebPage,
     CoderedLocationIndexPage,
-    CoderedLocationPage
+    CoderedLocationPage,
+    CoderedStreamFormPage,
+    CoderedWebPage
 )
 
 
@@ -138,4 +139,13 @@ class LocationIndexPage(CoderedLocationIndexPage):
     # Only allow LocationPages beneath this page.
     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
 
 def attempt_protected_media_value_conversion(request, value):
-    new_value = value
     try:
         if value.startswith(cr_settings['PROTECTED_MEDIA_URL']):
             new_value = get_protected_media_link(request, value)
+            return new_value
     except AttributeError:
         pass
-    return new_value
+
+    return value
 
 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.staticfiles.templatetags.staticfiles import static
 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.modeladmin.options import modeladmin_register
 from wagtail.core import hooks
 from wagtail.core.models import UserPagePermissionsProxy, get_page_models
 from wagtailcache.cache import clear_cache
 
 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')
 def global_admin_css():
@@ -37,6 +39,7 @@ def clear_wagtailcache(request, page):
 
 @hooks.register('filter_form_submissions_for_user')
 def codered_forms(user, editable_forms):
+    from coderedcms.models import CoderedFormMixin
     """
     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()
@@ -44,7 +47,7 @@ def codered_forms(user, editable_forms):
     """
     form_models = [
         model for model in get_page_models()
-        if issubclass(model, (AbstractForm, CoderedFormPage))
+        if issubclass(model, CoderedFormMixin)
     ]
     form_types = list(
         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-Encoding'] = content_encoding
     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 {
     border: none;
     font-weight: 600;
-    margin: unset;
+    margin: 1.5em 0 0 0;
     padding: 0;
 }
 div.body h1 {
     font-weight: 300;
     font-size: 2.5em;
-    padding-top: 1em;
     margin-bottom: 1em;
     line-height: 1;
 }
 div.body h2 {
     font-size: 1.5em;
-    margin-top: 1.5em;
 }
 div.body h3 {
     font-size: 1.2em;

+ 1 - 1
docs/conf.py

@@ -23,7 +23,7 @@ from coderedcms import __shortversion__
 # -- Project information -----------------------------------------------------
 
 project = 'CodeRed CMS'
-copyright = str(datetime.datetime.now().year) + ', CodeRed LLC'
+copyright = '2018-' + str(datetime.datetime.now().year) + ', CodeRed LLC'
 author = 'CodeRed LLC'
 
 # 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.
 * **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.
 

+ 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. 
 
-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.
 
@@ -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.
 
-Settings Tab Options
-~~~~~~~~~~~~~~~~~~~~
+Settings Tab
+~~~~~~~~~~~~
 
 * **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.

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

@@ -8,6 +8,7 @@ Page Types
     event_pages
     form_pages
     location_pages
+    stream_forms
     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
     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>`_). 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini