diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py index 811ee371bb330642fd2b52d926375fca35917198..866c57428b034800c956fd6298005f00ab566769 100644 --- a/opentech/apply/funds/models/submissions.py +++ b/opentech/apply/funds/models/submissions.py @@ -20,7 +20,7 @@ from wagtail.core.fields import StreamField from wagtail.contrib.forms.models import AbstractFormSubmission from opentech.apply.activity.messaging import messenger, MESSAGES -from opentech.apply.stream_forms.blocks import UploadableMediaBlock +from opentech.apply.stream_forms.blocks import FormFieldBlock, UploadableMediaBlock from opentech.apply.stream_forms.models import BaseStreamForm from opentech.apply.utils.blocks import MustIncludeFieldBlock @@ -192,7 +192,12 @@ class AddTransitions(models.base.ModelBase): return super().__new__(cls, name, bases, attrs, **kwargs) -class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmission, metaclass=AddTransitions): +class ApplicationSubmission( + WorkflowHelpers, + BaseStreamForm, + AbstractFormSubmission, + metaclass=ApplicationSubmissionMetaclass, +): field_template = 'funds/includes/submission_field.html' form_data = JSONField(encoder=DjangoJSONEncoder) @@ -386,14 +391,6 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss self.ensure_user_has_account() self.process_file_data() - @property - def must_include(self): - return { - field.block.name: field.id - for field in self.form_fields - if isinstance(field.block, MustIncludeFieldBlock) - } - def process_form_data(self): for field_name, field_id in self.must_include.items(): response = self.form_data.pop(field_id, None) @@ -479,36 +476,11 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss def can_have_determination(self): return self.in_determination_phase and not self.has_determination - @property - def raw_data(self): - data = self.form_data.copy() - for field_name, field_id in self.must_include.items(): - response = data.pop(field_name) - data[field_id] = response - return data - - def data_and_fields(self): - for stream_value in self.form_fields: - try: - data = self.form_data[stream_value.id] - except KeyError: - pass # It was a named field or a paragraph - else: - yield data, stream_value - - @property - def fields(self): - return [ - field.render(context={'data': data}) - for data, field in self.data_and_fields() - ] - - def render_answers(self): - return mark_safe(''.join(self.fields)) - def prepare_search_values(self): - for data, stream in self.data_and_fields(): - value = stream.block.get_searchable_content(stream.value, data) + for field_id in self.question_field_ids: + field = self.field(field_id) + data = self.data(field_id) + value = field.block.get_searchable_content(field.value, data) if value: if isinstance(value, list): yield ', '.join(value) @@ -519,15 +491,6 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss for field in ['email', 'title']: yield getattr(self, field) - def get_data(self): - # Updated for JSONField - form_data = self.form_data.copy() - form_data.update({ - 'submit_time': self.submit_time, - }) - - return form_data - def get_absolute_url(self): return reverse('funds:submissions:detail', args=(self.id,)) @@ -535,7 +498,7 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss # __getattribute__ allows correct error handling from django compared to __getattr__ # fall back to values defined on the data if item in REQUIRED_BLOCK_NAMES: - return self.get_data()[item] + return self.form_data[item] return super().__getattribute__(item) def __str__(self): @@ -544,6 +507,95 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss def __repr__(self): return f'<{self.__class__.__name__}: {self.user}, {self.round}, {self.page}>' + # Methods for accessing data on the submission + + def get_data(self): + # Updated for JSONField + form_data = self.form_data.copy() + form_data.update({ + 'submit_time': self.submit_time, + }) + + return form_data + + @property + def raw_data(self): + # Returns the data mapped by field id instead of the data stored using the must include + # values + data = self.form_data.copy() + for field_name, field_id in self.must_include.items(): + response = data.pop(field_name) + data[field_id] = response + return data + + def field(self, id): + try: + return self.fields[id] + except KeyError as e: + try: + actual_id = self.must_include[id] + except KeyError: + raise e + else: + return self.fields[actual_id] + + def data(self, id): + try: + return self.form_data[id] + except KeyError as e: + try: + transposed_must_include = {v:k for k,v in self.must_include.items()} + actual_id = transposed_must_include[id] + except KeyError: + # We have most likely progressed application forms so the data isnt in form_data + return None + else: + return self.form_data[actual_id] + + @property + def question_field_ids(self): + for field_id, field in self.fields.items(): + if isinstance(field.block, FormFieldBlock): + yield field_id + + @property + def raw_fields(self): + # Field ids to field class mapping - similar to raw_data + return { + field.id: field + for field in self.form_fields + } + + @property + def fields(self): + # ALl fields on the application + fields = self.raw_fields.copy() + for field_name, field_id in self.must_include.items(): + response = fields.pop(field_id) + fields[field_name] = response + return fields + + @property + def must_include(self): + return { + field.block.name: field.id + for field in self.form_fields + if isinstance(field.block, MustIncludeFieldBlock) + } + + def render_answer(self, field_id): + field = self.field(field_id) + data = self.data(field_id) + return field.render(context={'data': data}) + + def render_answers(self): + answers = [ + self.render_answer(field_id) + for field_id in self.question_field_ids + if field_id not in self.must_include + ] + return mark_safe(''.join(answers)) + @receiver(post_transition, sender=ApplicationSubmission) def log_status_update(sender, **kwargs): diff --git a/opentech/apply/funds/tests/factories/blocks.py b/opentech/apply/funds/tests/factories/blocks.py index 8d378e36123893fe29af73ade86a4f2c346fc4d8..77cc38d4c0f70518bdb3cb58e07eacdf48835dbc 100644 --- a/opentech/apply/funds/tests/factories/blocks.py +++ b/opentech/apply/funds/tests/factories/blocks.py @@ -2,9 +2,17 @@ import random import factory from opentech.apply.funds import blocks -from opentech.apply.stream_forms.testing.factories import FormFieldBlockFactory, CharFieldBlockFactory, \ - NumberFieldBlockFactory, RadioFieldBlockFactory, ImageFieldBlockFactory, FileFieldBlockFactory, \ - MultiFileFieldBlockFactory, StreamFieldUUIDFactory +from opentech.apply.stream_forms.testing.factories import ( + CharFieldBlockFactory, + FileFieldBlockFactory, + FormFieldBlockFactory, + ImageFieldBlockFactory, + MultiFileFieldBlockFactory, + NumberFieldBlockFactory, + RadioFieldBlockFactory, + ParagraphBlockFactory, + StreamFieldUUIDFactory, +) from opentech.apply.utils.testing.factories import RichTextFieldBlockFactory __all__ = ['CustomFormFieldsFactory', 'TitleBlockFactory', 'EmailBlockFactory', 'FullNameBlockFactory', 'ValueFieldBlockFactory'] @@ -31,6 +39,16 @@ class FullNameBlockFactory(FormFieldBlockFactory): model = blocks.FullNameBlock +class DurationBlockFactory(FormFieldBlockFactory): + class Meta: + model = blocks.DurationBlock + + @classmethod + def make_answer(cls, params=dict()): + choices = list(blocks.DurationBlock.DURATION_OPTIONS.keys()) + return random.choice(choices) + + class ValueFieldBlockFactory(FormFieldBlockFactory): class Meta: model = blocks.ValueBlock @@ -41,6 +59,7 @@ class ValueFieldBlockFactory(FormFieldBlockFactory): CustomFormFieldsFactory = StreamFieldUUIDFactory({ + 'duration': DurationBlockFactory, 'title': TitleBlockFactory, 'value': ValueFieldBlockFactory, 'email': EmailBlockFactory, @@ -52,4 +71,5 @@ CustomFormFieldsFactory = StreamFieldUUIDFactory({ 'image': ImageFieldBlockFactory, 'file': FileFieldBlockFactory, 'multi_file': MultiFileFieldBlockFactory, + 'text_markup': ParagraphBlockFactory, }) diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py index 286c340cd1fd4a05721107d15625c0a5aa5ec4cc..9dc63483098b9159a5544404e650756f51c3dd64 100644 --- a/opentech/apply/funds/tests/factories/models.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -42,25 +42,6 @@ __all__ = [ ] -def build_form(data, prefix=''): - if prefix: - prefix += '__' - - extras = defaultdict(dict) - for key, value in data.items(): - if 'form_fields' in key: - _, field, attr = key.split('__') - extras[field][attr] = value - - form_fields = {} - for i, field in enumerate(blocks.CustomFormFieldsFactory.factories): - form_fields[f'{prefix}form_fields__{i}__{field}__'] = '' - for attr, value in extras[field].items(): - form_fields[f'{prefix}form_fields__{i}__{field}__{attr}'] = value - - return form_fields - - class FundTypeFactory(wagtail_factories.PageFactory): class Meta: model = FundType @@ -89,16 +70,14 @@ class FundTypeFactory(wagtail_factories.PageFactory): @factory.post_generation def forms(self, create, extracted, **kwargs): if create: - from opentech.apply.review.tests.factories.models import build_form as review_build_form, ReviewFormFactory - fields = build_form(kwargs, prefix='form') - review_fields = review_build_form(kwargs) + from opentech.apply.review.tests.factories.models import ReviewFormFactory for _ in self.workflow.stages: # Generate a form based on all defined fields on the model ApplicationBaseFormFactory( application=self, - **fields, + **kwargs, ) - ReviewFormFactory(**review_fields) + ReviewFormFactory(**kwargs) class RequestForPartnersFactory(FundTypeFactory): @@ -144,12 +123,11 @@ class RoundFactory(wagtail_factories.PageFactory): @factory.post_generation def forms(self, create, extracted, **kwargs): if create: - fields = build_form(kwargs, prefix='form') for _ in self.workflow.stages: # Generate a form based on all defined fields on the model RoundBaseFormFactory( round=self, - **fields, + **kwargs, ) @@ -184,12 +162,11 @@ class LabFactory(wagtail_factories.PageFactory): @factory.post_generation def forms(self, create, extracted, **kwargs): if create: - fields = build_form(kwargs, prefix='form') for _ in self.workflow.stages: # Generate a form based on all defined fields on the model LabBaseFormFactory( lab=self, - **fields, + **kwargs, ) @@ -236,11 +213,6 @@ class ApplicationSubmissionFactory(factory.DjangoModelFactory): if create and reviewers: self.reviewers.set(reviewers) - @classmethod - def _generate(cls, strat, params): - params.update(**build_form(params)) - return super()._generate(strat, params) - class SealedSubmissionFactory(ApplicationSubmissionFactory): page = factory.SubFactory(RequestForPartnersFactory) diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index 44b07e3891733bd7d965a3a58d94d785edb59433..9b21854c42f8127783fd6889788ba0b09b6a4293 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -172,7 +172,7 @@ class TestRoundModelWorkflowAndForms(TestCase): del self.round.parent_page form = self.round.forms.first().form # Not ideal, would prefer better way to create the stream values - new_field = CustomFormFieldsFactory.generate(None, {'0__email__': ''}) + new_field = CustomFormFieldsFactory.generate(None, {}) form.form_fields = new_field form.save() for round_form, fund_form in itertools.zip_longest(self.round.forms.all(), self.fund.forms.all()): @@ -204,7 +204,8 @@ class TestFormSubmission(TestCase): page = page or self.round_page fields = page.get_form_fields() - data = {k: v for k, v in zip(fields, ['project', 0, email, name])} + # This needs to match the order of the fields defined on the form factory + data = {k: v for k, v in zip(fields, [1, 'project', 0, email, name])} request = make_request(user, data, method='post', site=self.site) try: @@ -419,3 +420,28 @@ class TestApplicationSubmission(TestCase): submission.form_data = {'title': title} submission.create_revision(draft=True) self.assertEqual(submission.revisions.count(), 2) + + +class TestSubmissionRenderMethods(TestCase): + def test_must_include_not_included_in_answers(self): + submission = ApplicationSubmissionFactory() + answers = submission.render_answers() + for name in submission.must_include: + field = submission.field(name) + self.assertNotIn(field.value['field_label'], answers) + + def test_normal_answers_included_in_answers(self): + submission = ApplicationSubmissionFactory() + answers = submission.render_answers() + for field_name in submission.question_field_ids: + if field_name not in submission.must_include: + field = submission.field(field_name) + self.assertIn(field.value['field_label'], answers) + + def test_paragraph_not_rendered_in_answers(self): + rich_text_label = 'My rich text label!' + submission = ApplicationSubmissionFactory( + form_fields__text_markup__value=rich_text_label + ) + answers = submission.render_answers() + self.assertNotIn(rich_text_label, answers) diff --git a/opentech/apply/review/tests/factories/models.py b/opentech/apply/review/tests/factories/models.py index 2be83c8a6bcd513a694b4cc9bec23f03c7f4711a..83ee6ce79beb240cc57884cc72f15d3e42119d2c 100644 --- a/opentech/apply/review/tests/factories/models.py +++ b/opentech/apply/review/tests/factories/models.py @@ -17,24 +17,6 @@ __all__ = ['ReviewFactory', 'ReviewFormFactory', 'ApplicationBaseReviewFormFacto 'ReviewFundTypeFactory', 'ReviewApplicationSubmissionFactory'] -def build_form(data, prefix=''): - if prefix: - prefix += '__' - - extras = defaultdict(dict) - for key, value in data.items(): - if 'form_fields' in key: - _, field, attr = key.split('__') - extras[field][attr] = value - - form_fields = {} - for i, field in enumerate(blocks.ReviewFormFieldsFactory.factories): - form_fields[f'{prefix}form_fields__{i}__{field}__'] = '' - for attr, value in extras[field].items(): - form_fields[f'{prefix}form_fields__{i}__{field}__{attr}'] = value - - return form_fields - class ReviewFormDataFactory(factory.DictFactory, metaclass=AddFormFieldsMetaclass): field_factory = blocks.ReviewFormFieldsFactory @@ -89,12 +71,11 @@ class ReviewFundTypeFactory(FundTypeFactory): @factory.post_generation def review_forms(self, create, extracted, **kwargs): if create: - fields = build_form(kwargs, prefix='form') for _ in self.workflow.stages: # Generate a form based on all defined fields on the model ApplicationBaseReviewFormFactory( application=self, - **fields + **kwargs ) diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py index 1d03a5670b7a3c7978245cb81a10ae1a8698949b..f1bdf4479318a462b333010c7dffe6e9c23ada6d 100644 --- a/opentech/apply/stream_forms/testing/factories.py +++ b/opentech/apply/stream_forms/testing/factories.py @@ -5,7 +5,8 @@ import uuid from django.core.files.uploadedfile import InMemoryUploadedFile import factory -from wagtail.core.blocks import CharBlock +from wagtail.core.blocks import RichTextBlock +from wagtail.core.rich_text import RichText import wagtail_factories from opentech.apply.stream_forms import blocks as stream_blocks @@ -28,6 +29,7 @@ class AddFormFieldsMetaclass(factory.base.FactoryMetaClass): wrapped_factories = { k: factory.SubFactory(AnswerFactory, sub_factory=v) for k, v in field_factory.factories.items() + if issubclass(v, FormFieldBlockFactory) } attrs.update(wrapped_factories) return super().__new__(mcs, class_name, bases, attrs) @@ -63,13 +65,21 @@ class FormDataFactory(factory.Factory, metaclass=AddFormFieldsMetaclass): return form_data -class CharBlockFactory(wagtail_factories.blocks.BlockFactory): +class ParagraphBlockFactory(wagtail_factories.blocks.BlockFactory): class Meta: - model = CharBlock + model = RichTextBlock + + @classmethod + def _create(cls, model_class, value): + value = RichText(value) + return super()._create(model_class, value) class FormFieldBlockFactory(wagtail_factories.StructBlockFactory): - default_value = factory.Faker('word') + default_value = factory.Faker('sentence') + field_label = factory.Faker('sentence') + help_text = factory.LazyAttribute(lambda o: str(o._Resolver__step.builder.factory_meta.model)) + class Meta: model = stream_blocks.FormFieldBlock @@ -138,8 +148,7 @@ class MultiFileFieldBlockFactory(UploadableMediaFactory): class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): def generate(self, step, params): - if not params: - params = self.build_form(params) + params = self.build_form(params) blocks = super().generate(step, params) ret_val = list() # Convert to JSON so we can add id before create @@ -151,10 +160,17 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): def build_form(self, data): extras = defaultdict(dict) + for field, value in data.items(): + # we dont care about position + name, attr = field.split('__') + extras[name] = {attr: value} form_fields = {} for i, field in enumerate(self.factories): - form_fields[f'{i}__{field}__'] = '' + if field == 'text_markup': + pass + else: + form_fields[f'{i}__{field}__'] = '' for attr, value in extras[field].items(): form_fields[f'{i}__{field}__{attr}'] = value diff --git a/opentech/apply/utils/blocks.py b/opentech/apply/utils/blocks.py index 3df0908995fc3a92ed3460762bafac95f9f6d24d..ee5aac70634505e766b063a2678858db0c5421ba 100644 --- a/opentech/apply/utils/blocks.py +++ b/opentech/apply/utils/blocks.py @@ -36,7 +36,7 @@ class RichTextFieldBlock(TextFieldBlock): icon = 'form' def get_searchable_content(self, value, data): - return bleach.clean(data, tags=[], strip=True) + return bleach.clean(data or '', tags=[], strip=True) class CustomFormFieldsBlock(StreamBlock):