diff --git a/opentech/apply/funds/models/mixins.py b/opentech/apply/funds/models/mixins.py index 50c3c0ccdb24e4e3d3e68df66a36c8260cdb2aba..fbc9a879df0c28d2c803ac1e566a257edeb5d529 100644 --- a/opentech/apply/funds/models/mixins.py +++ b/opentech/apply/funds/models/mixins.py @@ -22,33 +22,27 @@ class AccessFormData: # 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 + if field_id not in data: + response = data[field_name] + data[field_id] = response return data + def get_definitive_id(self, id): + if id in self.must_include: + return self.must_include[id] + return id + 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] + definitive_id = self.get_definitive_id(id) + return self.raw_fields[definitive_id] def data(self, id): + definitive_id = self.get_definitive_id(id) try: - return self.form_data[id] + return self.raw_data[definitive_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] + # We have most likely progressed application forms so the data isnt in form_data + return None @property def question_field_ids(self): diff --git a/opentech/apply/funds/templates/funds/includes/review_table.html b/opentech/apply/funds/templates/funds/includes/review_table.html index 795f47c1ad60a13add873f991a8fcd9a05def0f0..cacd82ba33e77deae9332d836e639a98d5aa2ef6 100644 --- a/opentech/apply/funds/templates/funds/includes/review_table.html +++ b/opentech/apply/funds/templates/funds/includes/review_table.html @@ -5,7 +5,7 @@ <tr class="tr tr--subchild light-grey-bg"> <th colspan="2"></th> <th>{{ object.reviews.submitted.recommendation|traffic_light }}</th> - <th>{{ object.reviews.submitted.score|floatformat|default_if_none:'-' }}</th> + <th>{{ object.reviews.submitted.get_score_display|default_if_none:'-' }}</th> </tr> {% include 'funds/includes/review_table_row.html' with reviews=staff_reviews %} {% endif %} diff --git a/opentech/apply/funds/templates/funds/includes/review_table_row.html b/opentech/apply/funds/templates/funds/includes/review_table_row.html index 18b05f47d4f41642624e0b0f7a62a125b01d2572..1717b7fe71c31cdddef0e56d00e2bb34e692dccb 100644 --- a/opentech/apply/funds/templates/funds/includes/review_table_row.html +++ b/opentech/apply/funds/templates/funds/includes/review_table_row.html @@ -6,7 +6,7 @@ <td>-</td> {% else %} <td class="reviews-sidebar__author" colspan="2"> - {% if request.user.is_apply_staff %} + {% if request.user.is_apply_staff or request.user == review.author %} <a href="{% url 'apply:submissions:reviews:review' submission_pk=review.submission.id pk=review.id %}"> <span>{{ review.author }}</span> </a> diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index 32389220da12b4abd0df8e937c299bc889a3ecb0..10e29363d5bccd2bfc70ba47f2fadb94e7dfe6d8 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -195,8 +195,8 @@ class TestFormSubmission(TestCase): def submit_form(self, page=None, email=None, name=None, user=AnonymousUser(), ignore_errors=False): page = page or self.round_page - fields = page.get_form_fields() + fields = page.forms.first().fields data = CustomFormFieldsFactory.form_response(fields) # Add our own data diff --git a/opentech/apply/review/blocks.py b/opentech/apply/review/blocks.py index d271c864c1b8a5649b31804c35aebd3c8fb5bb97..6398ae8c14f649c4824521d4e2f624928cfb32e7 100644 --- a/opentech/apply/review/blocks.py +++ b/opentech/apply/review/blocks.py @@ -22,7 +22,11 @@ class ScoreFieldBlock(OptionalFormFieldBlock): template = 'review/render_scored_answer_field.html' def render(self, value, context=None): - comment, score = json.loads(context['data']) + try: + comment, score = context['data'] + except ValueError: + # TODO: Remove json load as data moved away from JSON + comment, score = json.loads(context['data']) context.update(**{ 'comment': comment, 'score': RATE_CHOICES_DICT.get(int(score), RATE_CHOICE_NA) diff --git a/opentech/apply/review/fields.py b/opentech/apply/review/fields.py index aeaeaa79eb517c85006e86140f7eb216f9536836..169ca4bd95ea2a416d72f222c259194bb9e69022 100644 --- a/opentech/apply/review/fields.py +++ b/opentech/apply/review/fields.py @@ -1,12 +1,10 @@ -import json - from django import forms from tinymce import TinyMCE from django.forms import widgets from django.utils.safestring import mark_safe -from opentech.apply.review.options import RATE_CHOICES +from opentech.apply.review.options import RATE_CHOICES, NA from opentech.apply.utils.options import MCE_ATTRIBUTES_SHORT @@ -19,44 +17,22 @@ class ScoredAnswerWidget(forms.MultiWidget): super().__init__(_widgets, attrs) def decompress(self, value): + # We should only hit this on initialisation where we set the default to a list of None if value: - return json.loads(value) + return value return [None, None] def render(self, name, value, attrs=None, renderer=None): - """ - Render the widget as an HTML string. - Required for the correct rendering of the TinyMCE widget. - """ - if self.is_localized: - for widget in self.widgets: - widget.is_localized = self.is_localized - # value is a list of values, each corresponding to a widget - # in self.widgets. - if not isinstance(value, list): - value = self.decompress(value) - + context = self.get_context(name, value, attrs) rendered = [] - final_attrs = self.build_attrs(attrs) - input_type = final_attrs.pop('type', None) - id_ = final_attrs.get('id') - for i, widget in enumerate(self.widgets): - if input_type is not None: - widget.input_type = input_type - widget_name = '%s_%s' % (name, i) - try: - widget_value = value[i] - except IndexError: - widget_value = None - if id_: - widget_attrs = final_attrs.copy() - widget_attrs['id'] = '%s_%s' % (id_, i) - else: - widget_attrs = final_attrs - - rendered.append(widget.render(widget_name, widget_value, widget_attrs, renderer)) - - return ''.join([mark_safe(item) for item in rendered]) + # We need to explicitly call the render method on the tinymce widget + # MultiValueWidget just passes all the context into the template + for kwargs, widget in zip(context['widget']['subwidgets'], self.widgets): + name = kwargs['name'] + value = kwargs['value'] + attrs = kwargs['attrs'] + rendered.append(widget.render(name, value, attrs, renderer)) + return mark_safe(''.join([widget for widget in rendered])) class ScoredAnswerField(forms.MultiValueField): @@ -71,4 +47,7 @@ class ScoredAnswerField(forms.MultiValueField): super().__init__(fields=fields, *args, **kwargs) def compress(self, data_list): - return json.dumps(data_list) + if data_list: + return [data_list[0], int(data_list[1])] + else: + return ['', NA] diff --git a/opentech/apply/review/forms.py b/opentech/apply/review/forms.py index 74e1fdb150e0e450087adfd39a5ab530da185f9a..51a5f298d7391f93bf271f294ace0a03cbe8a14e 100644 --- a/opentech/apply/review/forms.py +++ b/opentech/apply/review/forms.py @@ -1,27 +1,12 @@ -import json - from django import forms from django.core.exceptions import NON_FIELD_ERRORS -from opentech.apply.review.blocks import ScoredAnswerField from opentech.apply.review.options import NA from opentech.apply.stream_forms.forms import StreamBaseForm -from .blocks import RecommendationBlock from .models import Review -def get_recommendation_field(fields): - for field in fields: - try: - block = field.block - except AttributeError: - pass - else: - if isinstance(block, RecommendationBlock): - return field.id - - class MixedMetaClass(type(StreamBaseForm), type(forms.ModelForm)): pass @@ -76,7 +61,7 @@ class ReviewModelForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaClass) def save(self, commit=True): self.instance.score = self.calculate_score(self.cleaned_data) - self.instance.recommendation = int(self.cleaned_data[get_recommendation_field(self.instance.form_fields)]) + self.instance.recommendation = int(self.cleaned_data[self.instance.reccomendation_field.id]) self.instance.is_draft = self.draft_button_name in self.data self.instance.form_data = self.cleaned_data['form_data'] @@ -90,21 +75,12 @@ class ReviewModelForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaClass) def calculate_score(self, data): scores = list() - for field in self.get_score_fields(): - value = json.loads(data.get(field, '[null, null]')) - - try: - score = int(value[1]) - except TypeError: - pass - else: - if score != NA: - scores.append(score) + for field in self.instance.score_fields: + score = data.get(field.id)[1] + if score != NA: + scores.append(score) try: return sum(scores) / len(scores) except ZeroDivisionError: - return 0 - - def get_score_fields(self): - return [field_name for field_name, field in self.fields.items() if isinstance(field, ScoredAnswerField)] + return NA diff --git a/opentech/apply/review/models.py b/opentech/apply/review/models.py index fff92008729805052d55e0e07e1fa2b8e1fbe176..c47a65c0d16a734a6dbe666607c18dc13a2d8b68 100644 --- a/opentech/apply/review/models.py +++ b/opentech/apply/review/models.py @@ -13,13 +13,51 @@ from opentech.apply.review.options import YES, NO, MAYBE, RECOMMENDATION_CHOICES from opentech.apply.stream_forms.models import BaseStreamForm from opentech.apply.users.models import User -from .blocks import ReviewCustomFormFieldsBlock +from .blocks import ( + ReviewCustomFormFieldsBlock, + RecommendationBlock, + RecommendationCommentsBlock, + ScoreFieldBlock, +) +from .options import NA -class ReviewForm(models.Model): - name = models.CharField(max_length=255) +class ReviewFormFieldsMixin(models.Model): + class Meta: + abstract = True + form_fields = StreamField(ReviewCustomFormFieldsBlock()) + @property + def score_fields(self): + return self._get_field_type(ScoreFieldBlock, many=True) + + @property + def reccomendation_field(self): + return self._get_field_type(RecommendationBlock) + + @property + def comment_field(self): + return self._get_field_type(RecommendationCommentsBlock) + + def _get_field_type(self, block_type, many=False): + fields = list() + for field in self.form_fields: + try: + if isinstance(field.block, block_type): + if many: + fields.append(field) + else: + return field + except AttributeError: + pass + if many: + return fields + + +class ReviewForm(ReviewFormFieldsMixin, models.Model): + name = models.CharField(max_length=255) + panels = [ FieldPanel('name'), StreamFieldPanel('form_fields'), @@ -52,7 +90,7 @@ class ReviewQuerySet(models.QuerySet): return self.by_reviewers().recommendation() def score(self): - return self.aggregate(models.Avg('score'))['score__avg'] + return self.exclude(score=NA).aggregate(models.Avg('score'))['score__avg'] def recommendation(self): recommendations = self.values_list('recommendation', flat=True) @@ -68,7 +106,7 @@ class ReviewQuerySet(models.QuerySet): return MAYBE -class Review(BaseStreamForm, AccessFormData, models.Model): +class Review(ReviewFormFieldsMixin, BaseStreamForm, AccessFormData, models.Model): submission = models.ForeignKey('funds.ApplicationSubmission', on_delete=models.CASCADE, related_name='reviews') revision = models.ForeignKey('funds.ApplicationRevision', on_delete=models.SET_NULL, related_name='reviews', null=True) author = models.ForeignKey( @@ -77,7 +115,6 @@ class Review(BaseStreamForm, AccessFormData, models.Model): ) form_data = JSONField(default=dict, encoder=DjangoJSONEncoder) - form_fields = StreamField(ReviewCustomFormFieldsBlock()) recommendation = models.IntegerField(verbose_name="Recommendation", choices=RECOMMENDATION_CHOICES, default=0) score = models.DecimalField(max_digits=10, decimal_places=1, default=0) @@ -92,6 +129,12 @@ class Review(BaseStreamForm, AccessFormData, models.Model): def outcome(self): return self.get_recommendation_display() + def get_comments_display(self, include_question=True): + return self.render_answer(self.comment_field.id, include_question=include_question) + + def get_score_display(self): + return '{:.1f}'.format(self.score) if self.score != NA else 'NA' + def get_absolute_url(self): return reverse('apply:reviews:review', args=(self.id,)) diff --git a/opentech/apply/review/templates/review/review_detail.html b/opentech/apply/review/templates/review/review_detail.html index 94182ff8fd81de68d0d9ea6f031010f4b77fb86c..8581f9d77c2c74931bbd6865501b5d6f935ff1c8 100644 --- a/opentech/apply/review/templates/review/review_detail.html +++ b/opentech/apply/review/templates/review/review_detail.html @@ -29,6 +29,8 @@ </div> <div class="rich-text rich-text--answers"> + {{ object.get_comments_display }} + {{ object.output_answers }} </div> {% endblock %} diff --git a/opentech/apply/review/tests/factories/blocks.py b/opentech/apply/review/tests/factories/blocks.py index 82cd4a011bddef0e86086bd13b114caa92b1d9d9..31d6290a9dd01a88fdffd2cc908d7733fc926e0f 100644 --- a/opentech/apply/review/tests/factories/blocks.py +++ b/opentech/apply/review/tests/factories/blocks.py @@ -35,10 +35,12 @@ class ScoreFieldBlockFactory(FormFieldBlockFactory): @classmethod def make_form_answer(cls, params=dict()): - return { - 'description': factory.Faker('paragraph').generate(params), + defaults = { + 'description': factory.Faker('paragraph').generate({}), 'score': random.randint(1, 5), } + defaults.update(params) + return defaults ReviewFormFieldsFactory = StreamFieldUUIDFactory({ diff --git a/opentech/apply/review/tests/test_views.py b/opentech/apply/review/tests/test_views.py index 91dd7202dba7302aa274636ecfd9161eeb87d288..682500b50994cdd428969f42690d3ef1b12dce19 100644 --- a/opentech/apply/review/tests/test_views.py +++ b/opentech/apply/review/tests/test_views.py @@ -4,7 +4,9 @@ from opentech.apply.funds.tests.factories.models import ApplicationSubmissionFac from opentech.apply.users.tests.factories import StaffFactory, UserFactory from opentech.apply.utils.testing.tests import BaseViewTestCase -from .factories import ReviewFactory, ReviewFormFieldsFactory +from .factories import ReviewFactory, ReviewFormFieldsFactory, ReviewFormFactory +from ..models import Review +from ..options import NA class StaffReviewsTestCase(BaseViewTestCase): @@ -46,20 +48,32 @@ class StaffReviewListingTestCase(BaseViewTestCase): for review in reviews: self.assertContains(response, review.author.full_name) + def test_draft_reviews_dont_appear(self): + submission = ApplicationSubmissionFactory() + review = ReviewFactory.create(submission=submission, is_draft=True) + response = self.get_page(submission, 'list') + self.assertContains(response, submission.title) + self.assertContains(response, reverse('funds:submissions:detail', kwargs={'pk': submission.id})) + self.assertNotContains(response, review.author.full_name) + class StaffReviewFormTestCase(BaseViewTestCase): user_factory = StaffFactory url_name = 'funds:submissions:reviews:{}' base_view_name = 'review' + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.submission = ApplicationSubmissionFactory(status='internal_review') + def get_kwargs(self, instance): return {'submission_pk': instance.id} def test_can_access_form(self): - submission = ApplicationSubmissionFactory(status='internal_review') - response = self.get_page(submission, 'form') - self.assertContains(response, submission.title) - self.assertContains(response, reverse('funds:submissions:detail', kwargs={'pk': submission.id})) + response = self.get_page(self.submission, 'form') + self.assertContains(response, self.submission.title) + self.assertContains(response, reverse('funds:submissions:detail', kwargs={'pk': self.submission.id})) def test_cant_access_wrong_status(self): submission = ApplicationSubmissionFactory(rejected=True) @@ -67,41 +81,103 @@ class StaffReviewFormTestCase(BaseViewTestCase): self.assertEqual(response.status_code, 403) def test_cant_resubmit_review(self): - submission = ApplicationSubmissionFactory(status='internal_review') - ReviewFactory(submission=submission, author=self.user) - response = self.post_page(submission, {'data': 'value'}, 'form') + ReviewFactory(submission=self.submission, author=self.user) + response = self.post_page(self.submission, {'data': 'value'}, 'form') self.assertEqual(response.context['has_submitted_review'], True) self.assertEqual(response.context['title'], 'Update Review draft') def test_can_edit_draft_review(self): - submission = ApplicationSubmissionFactory(status='internal_review') - ReviewFactory(submission=submission, author=self.user, is_draft=True) - response = self.post_page(submission, {'data': 'value'}, 'form') + ReviewFactory(submission=self.submission, author=self.user, is_draft=True) + response = self.get_page(self.submission, 'form') self.assertEqual(response.context['has_submitted_review'], False) self.assertEqual(response.context['title'], 'Update Review draft') def test_revision_captured_on_review(self): - submission = ApplicationSubmissionFactory(status='internal_review') - field_ids = [f.id for f in submission.round.review_forms.first().fields] + form = self.submission.round.review_forms.first() - data = ReviewFormFieldsFactory.form_response(field_ids) + data = ReviewFormFieldsFactory.form_response(form.fields) - self.post_page(submission, data, 'form') - review = submission.reviews.first() - self.assertEqual(review.revision, submission.live_revision) + self.post_page(self.submission, data, 'form') + review = self.submission.reviews.first() + self.assertEqual(review.revision, self.submission.live_revision) def test_can_submit_draft_review(self): - submission = ApplicationSubmissionFactory(status='internal_review') - field_ids = [f.id for f in submission.round.review_forms.first().fields] + form = self.submission.round.review_forms.first() - data = ReviewFormFieldsFactory.form_response(field_ids) + data = ReviewFormFieldsFactory.form_response(form.fields) data['save_draft'] = True - self.post_page(submission, data, 'form') - review = submission.reviews.first() + self.post_page(self.submission, data, 'form') + review = self.submission.reviews.first() self.assertTrue(review.is_draft) self.assertIsNone(review.revision) +class TestReviewScore(BaseViewTestCase): + user_factory = StaffFactory + url_name = 'funds:submissions:reviews:{}' + base_view_name = 'review' + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.submission = ApplicationSubmissionFactory(status='internal_review') + + def get_kwargs(self, instance): + return {'submission_pk': instance.id} + + def submit_review_scores(self, *scores): + if scores: + form = ReviewFormFactory(form_fields__multiple__score=len(scores)) + else: + form = ReviewFormFactory(form_fields__exclude__score=True) + review_form = self.submission.round.review_forms.first() + review_form.form = form + review_form.save() + + data = ReviewFormFieldsFactory.form_response(form.form_fields, { + field.id: {'score': score} + for field, score in zip(form.score_fields, scores) + }) + + # Make a new person for every review + self.client.force_login(self.user_factory()) + response = self.post_page(self.submission, data, 'form') + self.assertIn( + 'funds/applicationsubmission_admin_detail.html', + response.template_name, + msg='Failed to post the form correctly' + ) + self.client.force_login(self.user) + return self.submission.reviews.first() + + def test_score_calculated(self): + review = self.submit_review_scores(5) + self.assertEqual(review.score, 5) + + def test_average_score_calculated(self): + review = self.submit_review_scores(1, 5) + self.assertEqual(review.score, (1 + 5) / 2) + + def test_no_score_is_NA(self): + review = self.submit_review_scores() + self.assertEqual(review.score, NA) + + def test_na_not_included_in_review_average(self): + review = self.submit_review_scores(NA, 5) + self.assertEqual(review.score, 5) + + def test_na_not_included_reviews_average(self): + self.submit_review_scores(NA) + self.assertIsNone(Review.objects.score()) + + def test_na_not_included_multiple_reviews_average(self): + self.submit_review_scores(NA) + self.submit_review_scores(5) + + self.assertEqual(Review.objects.count(), 2) + self.assertEqual(Review.objects.score(), 5) + + class UserReviewFormTestCase(BaseViewTestCase): user_factory = UserFactory url_name = 'funds:submissions:reviews:{}' diff --git a/opentech/apply/review/views.py b/opentech/apply/review/views.py index 08f7838455ade3596759823f30bd9a760545a5e1..cc98a147e48782e65de43a0eb1eba483260ae0f4 100644 --- a/opentech/apply/review/views.py +++ b/opentech/apply/review/views.py @@ -9,7 +9,7 @@ from wagtail.core.blocks import RichTextBlock from opentech.apply.activity.messaging import messenger, MESSAGES from opentech.apply.funds.models import ApplicationSubmission -from opentech.apply.review.blocks import RecommendationBlock +from opentech.apply.review.blocks import RecommendationBlock, RecommendationCommentsBlock from opentech.apply.review.forms import ReviewModelForm from opentech.apply.stream_forms.models import BaseStreamForm from opentech.apply.users.decorators import staff_required @@ -119,9 +119,12 @@ class ReviewListView(ListView): def get_queryset(self): self.submission = get_object_or_404(ApplicationSubmission, id=self.kwargs['submission_pk']) - self.queryset = self.model.objects.filter(submission=self.submission) + self.queryset = self.model.objects.filter(submission=self.submission, is_draft=False) return super().get_queryset() + def should_display(self, field): + return not isinstance(field.block, (RecommendationBlock, RecommendationCommentsBlock, RichTextBlock)) + def get_context_data(self, **kwargs): review_data = {} @@ -130,13 +133,15 @@ class ReviewListView(ListView): review_data['score'] = {'question': 'Overall Score', 'answers': list()} review_data['recommendation'] = {'question': 'Recommendation', 'answers': list()} review_data['revision'] = {'question': 'Revision', 'answers': list()} + review_data['comments'] = {'question': 'Comments', 'answers': list()} responses = self.object_list.count() for i, review in enumerate(self.object_list): review_data['title']['answers'].append(str(review.author)) - review_data['score']['answers'].append(str(review.score)) + review_data['score']['answers'].append(str(review.get_score_display())) review_data['recommendation']['answers'].append(review.get_recommendation_display()) + review_data['comments']['answers'].append(review.get_comments_display(include_question=False)) if review.for_latest: revision = 'Current' else: @@ -146,7 +151,7 @@ class ReviewListView(ListView): for field_id in review.fields: field = review.field(field_id) data = review.data(field_id) - if not isinstance(field.block, (RecommendationBlock, RichTextBlock)): + if self.should_display(field): question = field.value['field_label'] review_data.setdefault(field.id, {'question': question, 'answers': [''] * responses}) review_data[field.id]['answers'][i] = field.block.render(None, {'data': data}) diff --git a/opentech/apply/stream_forms/blocks.py b/opentech/apply/stream_forms/blocks.py index 0b850540c8f7008057098a7899d3ccdb6fdd52b3..117192eebe74114ebb19e9dcff4e67bbd5870215 100644 --- a/opentech/apply/stream_forms/blocks.py +++ b/opentech/apply/stream_forms/blocks.py @@ -47,8 +47,8 @@ class FormFieldBlock(StructBlock): return kwargs def get_field(self, struct_value): - return self.get_field_class(struct_value)( - **self.get_field_kwargs(struct_value)) + field_kwargs = self.get_field_kwargs(struct_value) + return self.get_field_class(struct_value)(**field_kwargs) def get_context(self, value, parent_context): context = super().get_context(value, parent_context) diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py index fb7630a07135dcfbf24786cab258727a49d1257f..f742d6c50628280aacbf96197134fb594abe6566 100644 --- a/opentech/apply/stream_forms/testing/factories.py +++ b/opentech/apply/stream_forms/testing/factories.py @@ -176,27 +176,43 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): def build_form(self, data): extras = defaultdict(dict) + exclusions = [] + multiples = dict() for field, value in data.items(): # we dont care about position name, attr = field.split('__') - extras[name] = {attr: value} + if name == 'exclude': + exclusions.append(attr) + elif name == 'multiple': + multiples[attr] = value + else: + extras[name] = {attr: value} + + defined_both = set(exclusions) & set(multiples) + if defined_both: + raise ValueError( + 'Cant exclude and provide multiple at the same time: {}'.format(', '.join(defined_both)) + ) form_fields = {} - for i, field in enumerate(self.factories): - if field == 'text_markup': + field_count = 0 + for field in self.factories: + if field == 'text_markup' or field in exclusions: pass else: - form_fields[f'{i}__{field}__'] = '' + for _ in range(multiples.get(field, 1)): + form_fields[f'{field_count}__{field}__'] = '' + field_count += 1 for attr, value in extras[field].items(): - form_fields[f'{i}__{field}__{attr}'] = value + form_fields[f'{field_count}__{field}__{attr}'] = value return form_fields - def form_response(self, fields): + def form_response(self, fields, field_values=dict()): data = { - field: factory.make_form_answer() - for field, factory in zip(fields, self.factories.values()) - if hasattr(factory, 'make_form_answer') + field.id: self.factories[field.block.name].make_form_answer(field_values.get(field.id, {})) + for field in fields + if hasattr(self.factories[field.block.name], 'make_form_answer') } return flatten_for_form(data)