From 391e837db6da306db6d15a4df568ea0833b45a52 Mon Sep 17 00:00:00 2001 From: Dan Braghis <dan.braghis@torchbox.com> Date: Tue, 17 Jul 2018 17:37:50 +0100 Subject: [PATCH] Move required field block definitions to utils --- opentech/apply/funds/blocks.py | 180 +++------------------------------ opentech/apply/funds/models.py | 7 +- opentech/apply/utils/blocks.py | 166 ++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 170 deletions(-) create mode 100644 opentech/apply/utils/blocks.py diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py index 7109e0c13..a8e0c79a4 100644 --- a/opentech/apply/funds/blocks.py +++ b/opentech/apply/funds/blocks.py @@ -1,184 +1,28 @@ -from collections import Counter - -import bleach from django import forms -from django.core.exceptions import ValidationError -from django.forms.utils import ErrorList from django.utils.translation import ugettext_lazy as _ -from django.utils.text import mark_safe - -from wagtail.core.blocks import StaticBlock, StreamValue - -from tinymce.widgets import TinyMCE -from opentech.apply.stream_forms.blocks import ( - FormFieldsBlock, - FormFieldBlock, - TextFieldBlock, -) -from opentech.apply.categories.blocks import CategoryQuestionBlock from addressfield.fields import AddressField +from opentech.apply.categories.blocks import CategoryQuestionBlock +from opentech.apply.stream_forms.blocks import FormFieldsBlock +from opentech.apply.utils.blocks import MustIncludeFieldBlock, CustomFormFieldsBlock -def find_duplicates(items): - counted = Counter(items) - duplicates = [ - name for name, count in counted.items() if count > 1 - ] - return duplicates - - -def prettify_names(sequence): - return [nice_field_name(item) for item in sequence] - - -def nice_field_name(name): - return name.title().replace('_', ' ') - - -class RichTextFieldBlock(TextFieldBlock): - widget = TinyMCE(mce_attrs={ - 'elementpath': False, - 'branding': False, - 'toolbar1': 'undo redo | styleselect | bold italic | bullist numlist | link', - 'style_formats': [ - {'title': 'Headers', 'items': [ - {'title': 'Header 1', 'format': 'h1'}, - {'title': 'Header 2', 'format': 'h2'}, - {'title': 'Header 3', 'format': 'h3'}, - ]}, - {'title': 'Inline', 'items': [ - {'title': 'Bold', 'icon': 'bold', 'format': 'bold'}, - {'title': 'Italic', 'icon': 'italic', 'format': 'italic'}, - {'title': 'Underline', 'icon': 'underline', 'format': 'underline'}, - ]}, - ], - }) - - class Meta: - label = _('Rich text field') - icon = 'form' - - def get_searchable_content(self, value, data): - return bleach.clean(data, tags=[], strip=True) - - -class CustomFormFieldsBlock(FormFieldsBlock): - rich_text = RichTextFieldBlock(group=_('Fields')) - category = CategoryQuestionBlock(group=_('Custom')) +class ApplicationMustIncludeFieldBlock(MustIncludeFieldBlock): + pass - def __init__(self, *args, **kwargs): - child_blocks = [(block.name, block(group=_('Required'))) for block in MustIncludeFieldBlock.__subclasses__()] - super().__init__(child_blocks, *args, **kwargs) - - def clean(self, value): - try: - value = super().clean(value) - except ValidationError as e: - error_dict = e.params - else: - error_dict = dict() - - block_types = [block.block_type for block in value] - missing = set(REQUIRED_BLOCK_NAMES) - set(block_types) - - duplicates = [ - name for name in find_duplicates(block_types) - if name in REQUIRED_BLOCK_NAMES - ] - - all_errors = list() - if missing: - all_errors.append( - 'You are missing the following required fields: {}'.format(', '.join(prettify_names(missing))) - ) - - if duplicates: - all_errors.append( - 'You have duplicates of the following required fields: {}'.format(', '.join(prettify_names(duplicates))) - ) - for i, block_name in enumerate(block_types): - if block_name in duplicates: - self.add_error_to_child(error_dict, i, 'info', 'Duplicate field') - - if all_errors or error_dict: - error_dict['__all__'] = all_errors - raise ValidationError('Error', params=error_dict) - - return value - - def add_error_to_child(self, errors, child_number, field, message): - new_error = ErrorList([message]) - try: - errors[child_number].data[0].params[field] = new_error - except KeyError: - errors[child_number] = ErrorList( - [ValidationError('Error', params={field: new_error})] - ) - - def to_python(self, value): - # If the data type is missing, fallback to a CharField - for child_data in value: - if child_data['type'] not in self.child_blocks: - child_data['type'] = 'char' - - return StreamValue(self, value, is_lazy=True) - - -class MustIncludeStatic(StaticBlock): - """Helper block which displays additional information about the must include block and - helps display the error in a noticeable way. - """ - def __init__(self, *args, description='', **kwargs): - self.description = description - super().__init__(*args, **kwargs) - class Meta: - admin_text = 'Must be included in the form only once.' - - def render_form(self, *args, **kwargs): - errors = kwargs.pop('errors') - if errors: - # Pretend the error is a readonly input so that we get nice formatting - # Issue discussed here: https://github.com/wagtail/wagtail/issues/4122 - error_message = '<div class="error"><input readonly placeholder="{}"></div>'.format(errors[0]) - else: - error_message = '' - form = super().render_form(*args, **kwargs) - form = '<br>'.join([self.description, form]) + error_message - return mark_safe(form) - - def deconstruct(self): - return ('wagtail.core.blocks.static_block.StaticBlock', (), {}) - - -class MustIncludeFieldBlock(FormFieldBlock): - """Any block inheriting from this will need to be included in the application forms - This data will also be available to query on the submission object - """ - def __init__(self, *args, **kwargs): - info_name = f'{self.name.title()} Field' - child_blocks = [('info', MustIncludeStatic(label=info_name, description=self.description))] - super().__init__(child_blocks, *args, **kwargs) - - def get_field_kwargs(self, struct_value): - kwargs = super().get_field_kwargs(struct_value) - kwargs['required'] = True - return kwargs - - -class TitleBlock(MustIncludeFieldBlock): +class TitleBlock(ApplicationMustIncludeFieldBlock): name = 'title' description = 'The title of the project' -class ValueBlock(MustIncludeFieldBlock): +class ValueBlock(ApplicationMustIncludeFieldBlock): name = 'value' description = 'The value of the project' widget = forms.NumberInput -class EmailBlock(MustIncludeFieldBlock): +class EmailBlock(ApplicationMustIncludeFieldBlock): name = 'email' description = 'The applicant email address' widget = forms.EmailInput @@ -187,7 +31,7 @@ class EmailBlock(MustIncludeFieldBlock): icon = 'mail' -class AddressFieldBlock(MustIncludeFieldBlock): +class AddressFieldBlock(ApplicationMustIncludeFieldBlock): name = 'address' description = 'The postal address of the user' @@ -198,7 +42,7 @@ class AddressFieldBlock(MustIncludeFieldBlock): icon = 'home' -class FullNameBlock(MustIncludeFieldBlock): +class FullNameBlock(ApplicationMustIncludeFieldBlock): name = 'full_name' description = 'Full name' @@ -206,4 +50,6 @@ class FullNameBlock(MustIncludeFieldBlock): icon = 'user' -REQUIRED_BLOCK_NAMES = [block.name for block in MustIncludeFieldBlock.__subclasses__()] +class ApplicationCustomFormFieldsBlock(CustomFormFieldsBlock, FormFieldsBlock): + category = CategoryQuestionBlock(group=_('Custom')) + required_blocks = ApplicationMustIncludeFieldBlock.__subclasses__() diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py index 196f7f48b..74d0017f1 100644 --- a/opentech/apply/funds/models.py +++ b/opentech/apply/funds/models.py @@ -36,9 +36,10 @@ from wagtail.contrib.forms.models import AbstractEmailForm, AbstractFormSubmissi from opentech.apply.stream_forms.blocks import UploadableMediaBlock from opentech.apply.stream_forms.models import AbstractStreamForm, BaseStreamForm from opentech.apply.users.groups import REVIEWER_GROUP_NAME, STAFF_GROUP_NAME +from opentech.apply.utils.blocks import MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES from .admin_forms import WorkflowFormAdminForm -from .blocks import CustomFormFieldsBlock, MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES +from .blocks import ApplicationCustomFormFieldsBlock from .edit_handlers import FilteredFieldPanel, ReadOnlyPanel, ReadOnlyInlinePanel from .workflow import ( active_statuses, @@ -257,7 +258,7 @@ class RoundForm(AbstractRelatedForm): class ApplicationForm(models.Model): name = models.CharField(max_length=255) - form_fields = StreamField(CustomFormFieldsBlock()) + form_fields = StreamField(ApplicationCustomFormFieldsBlock()) panels = [ FieldPanel('name'), @@ -603,7 +604,7 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss field_template = 'funds/includes/submission_field.html' form_data = JSONField(encoder=DjangoJSONEncoder) - form_fields = StreamField(CustomFormFieldsBlock()) + form_fields = StreamField(ApplicationCustomFormFieldsBlock()) page = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT) round = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT, related_name='submissions', null=True) lead = models.ForeignKey( diff --git a/opentech/apply/utils/blocks.py b/opentech/apply/utils/blocks.py new file mode 100644 index 000000000..73dd805b6 --- /dev/null +++ b/opentech/apply/utils/blocks.py @@ -0,0 +1,166 @@ +from collections import Counter + +import bleach +from django.core.exceptions import ValidationError +from django.forms.utils import ErrorList +from django.utils.translation import ugettext_lazy as _ +from django.utils.text import mark_safe + +from wagtail.core.blocks import StaticBlock, StreamValue, StreamBlock + +from tinymce.widgets import TinyMCE + +from opentech.apply.stream_forms.blocks import FormFieldBlock, TextFieldBlock + + +def find_duplicates(items): + counted = Counter(items) + duplicates = [ + name for name, count in counted.items() if count > 1 + ] + return duplicates + + +def prettify_names(sequence): + return [nice_field_name(item) for item in sequence] + + +def nice_field_name(name): + return name.title().replace('_', ' ') + + +class RichTextFieldBlock(TextFieldBlock): + widget = TinyMCE(mce_attrs={ + 'elementpath': False, + 'branding': False, + 'toolbar1': 'undo redo | styleselect | bold italic | bullist numlist | link', + 'style_formats': [ + {'title': 'Headers', 'items': [ + {'title': 'Header 1', 'format': 'h1'}, + {'title': 'Header 2', 'format': 'h2'}, + {'title': 'Header 3', 'format': 'h3'}, + ]}, + {'title': 'Inline', 'items': [ + {'title': 'Bold', 'icon': 'bold', 'format': 'bold'}, + {'title': 'Italic', 'icon': 'italic', 'format': 'italic'}, + {'title': 'Underline', 'icon': 'underline', 'format': 'underline'}, + ]}, + ], + }) + + class Meta: + label = _('Rich text field') + icon = 'form' + + def get_searchable_content(self, value, data): + return bleach.clean(data, tags=[], strip=True) + + +class CustomFormFieldsBlock(StreamBlock): + rich_text = RichTextFieldBlock(group=_('Fields')) + required_blocks = None + required_block_names = None + + def __init__(self, *args, **kwargs): + child_blocks = [(block.name, block(group=_('Required'))) for block in self.required_blocks] + self.required_block_names = [block.name for block in self.required_blocks] + + super().__init__(child_blocks, *args, **kwargs) + + def clean(self, value): + try: + value = super().clean(value) + except ValidationError as e: + error_dict = e.params + else: + error_dict = dict() + + block_types = [block.block_type for block in value] + missing = set(self.required_block_names) - set(block_types) + + duplicates = [ + name for name in find_duplicates(block_types) + if name in self.required_block_names + ] + + all_errors = list() + if missing: + all_errors.append( + 'You are missing the following required fields: {}'.format(', '.join(prettify_names(missing))) + ) + + if duplicates: + all_errors.append( + 'You have duplicates of the following required fields: {}'.format(', '.join(prettify_names(duplicates))) + ) + for i, block_name in enumerate(block_types): + if block_name in duplicates: + self.add_error_to_child(error_dict, i, 'info', 'Duplicate field') + + if all_errors or error_dict: + error_dict['__all__'] = all_errors + raise ValidationError('Error', params=error_dict) + + return value + + def add_error_to_child(self, errors, child_number, field, message): + new_error = ErrorList([message]) + try: + errors[child_number].data[0].params[field] = new_error + except KeyError: + errors[child_number] = ErrorList( + [ValidationError('Error', params={field: new_error})] + ) + + def to_python(self, value): + # If the data type is missing, fallback to a CharField + for child_data in value: + if child_data['type'] not in self.child_blocks: + child_data['type'] = 'char' + + return StreamValue(self, value, is_lazy=True) + + +class MustIncludeStatic(StaticBlock): + """Helper block which displays additional information about the must include block and + helps display the error in a noticeable way. + """ + def __init__(self, *args, description='', **kwargs): + self.description = description + super().__init__(*args, **kwargs) + + class Meta: + admin_text = 'Must be included in the form only once.' + + def render_form(self, *args, **kwargs): + errors = kwargs.pop('errors') + if errors: + # Pretend the error is a readonly input so that we get nice formatting + # Issue discussed here: https://github.com/wagtail/wagtail/issues/4122 + error_message = '<div class="error"><input readonly placeholder="{}"></div>'.format(errors[0]) + else: + error_message = '' + form = super().render_form(*args, **kwargs) + form = '<br>'.join([self.description, form]) + error_message + return mark_safe(form) + + def deconstruct(self): + return ('wagtail.core.blocks.static_block.StaticBlock', (), {}) + + +class MustIncludeFieldBlock(FormFieldBlock): + """Any block inheriting from this will need to be included in the application forms + This data will also be available to query on the submission object + """ + def __init__(self, *args, **kwargs): + info_name = f'{self.name.title()} Field' + child_blocks = [('info', MustIncludeStatic(label=info_name, description=self.description))] + super().__init__(child_blocks, *args, **kwargs) + + def get_field_kwargs(self, struct_value): + kwargs = super().get_field_kwargs(struct_value) + kwargs['required'] = True + return kwargs + + +REQUIRED_BLOCK_NAMES = [block.name for block in MustIncludeFieldBlock.__subclasses__()] -- GitLab