diff --git a/formly/fields.py b/formly/fields.py new file mode 100644 index 0000000..3096daf --- /dev/null +++ b/formly/fields.py @@ -0,0 +1,25 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +class LimitedMultipleChoiceField(forms.MultipleChoiceField): + def __init__(self, *args, **kwargs): + self.maximum_choices = kwargs.pop("maximum_choices") + + self.default_error_messages.update({ + 'maximum_choices': _('You may select at most %(maximum)d choices (%(selected)d selected)') + }) + + super(LimitedMultipleChoiceField, self).__init__(*args, **kwargs) + + def validate(self, value): + super(LimitedMultipleChoiceField, self).validate(value) + + selected_count = len(value) + if self.maximum_choices and selected_count > self.maximum_choices: + raise ValidationError( + self.error_messages['maximum_choices'], + code='maximum_choices', + params={'maximum': self.maximum_choices, 'selected': selected_count}, + ) diff --git a/formly/models.py b/formly/models.py index dc32536..315b05d 100644 --- a/formly/models.py +++ b/formly/models.py @@ -12,6 +12,7 @@ from jsonfield import JSONField +from .fields import LimitedMultipleChoiceField from .forms import MultipleTextField, MultiTextWidget from .forms.widgets import LikertSelect, RatingSelect @@ -362,6 +363,8 @@ def _get_field_class(self, choices): if self.field_type in [Field.CHECKBOX_FIELD, Field.SELECT_FIELD, Field.RADIO_CHOICES, Field.LIKERT_FIELD, Field.RATING_FIELD]: kwargs.update({"choices": choices}) + if self.field_type == Field.CHECKBOX_FIELD: + kwargs.update({"maximum_choices": self.maximum_choices}) elif self.field_type == Field.MULTIPLE_TEXT: kwargs.update({ "fields_length": self.expected_answers, @@ -406,7 +409,7 @@ def _get_field_class(self, choices): ) ), Field.CHECKBOX_FIELD: dict( - field_class=forms.MultipleChoiceField, + field_class=LimitedMultipleChoiceField, kwargs=dict( widget=forms.CheckboxSelectMultiple() ) diff --git a/formly/tests/tests.py b/formly/tests/tests.py index 02915cc..b4bc4db 100644 --- a/formly/tests/tests.py +++ b/formly/tests/tests.py @@ -1,9 +1,11 @@ +from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from django.test import TestCase from ..models import ( Field, Survey, + FieldChoice, ) User = get_user_model() @@ -44,3 +46,28 @@ def test_text_field_form_field_render(self): ) # Ensure no exception when field is instantiated self.assertTrue(field.form_field()) + + def test_multiplechoice_field_choice_limit(self): + """ + Enforce maximum_choices on multiple choice fields that allow + multiple answers. + """ + survey = Survey.objects.create( + name="multiple choice test", + creator=self.user, + ) + field = Field.objects.create( + survey=survey, + label="multiple choice field", + field_type=Field.CHECKBOX_FIELD, + maximum_choices=1, + ordinal=0, + ) + choice_pks = [] + for label in ["a", "b"]: + choice = FieldChoice.objects.create(label=label, field=field) + choice_pks.append(choice.pk) + + form_field = field.form_field() + with self.assertRaises(ValidationError): + form_field.clean(choice_pks)