diff --git a/example/app/admin.py b/example/app/admin.py index 4a9e6b1..21b922a 100644 --- a/example/app/admin.py +++ b/example/app/admin.py @@ -16,10 +16,12 @@ from django.contrib import admin +from .forms import BookForm from .models import Book class BookAdmin(admin.ModelAdmin): + form = BookForm list_display = ('title', 'categories', 'tags', 'published_in') diff --git a/example/app/fixtures/app_data.json b/example/app/fixtures/app_data.json index b61a466..72987c8 100644 --- a/example/app/fixtures/app_data.json +++ b/example/app/fixtures/app_data.json @@ -6,6 +6,7 @@ "title": "My book 1", "tags": "sex,work,happy", "categories": "1,3,5", + "tabs_with_other": "sex,|other_option", "published_in": "BC,AL,AK", "chapters": "1" } diff --git a/example/app/forms.py b/example/app/forms.py new file mode 100644 index 0000000..ec53bde --- /dev/null +++ b/example/app/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from .models import Book + + +class BookForm(forms.ModelForm): + class Meta: + model = Book + fields = '__all__' diff --git a/example/app/migrations/0002_auto_20200421_0403.py b/example/app/migrations/0002_auto_20200421_0403.py new file mode 100644 index 0000000..2434e3c --- /dev/null +++ b/example/app/migrations/0002_auto_20200421_0403.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.5 on 2020-04-21 09:03 + +from django.db import migrations +import multiselectfield.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='tabs_with_other', + field=multiselectfield.db.fields.MultiSelectWithOtherField(blank=True, choices=[('sex', 'Sex'), ('work', 'Work'), ('happy', 'Happy'), ('food', 'Food'), ('field', 'Field'), ('boring', 'Boring'), ('interesting', 'Interesting'), ('huge', 'Huge'), ('nice', 'Nice'), ('other', 'Other')], max_length=154, null=True), + ), + migrations.AlterField( + model_name='book', + name='published_in', + field=multiselectfield.db.fields.MultiSelectField(choices=[('Canada - Provinces', (('AB', 'Alberta'), ('BC', 'British Columbia'))), ('USA - States', (('AK', 'Alaska'), ('AL', 'Alabama'), ('AZ', 'Arizona')))], max_length=31, verbose_name='Province or State'), + ), + ] diff --git a/example/app/models.py b/example/app/models.py index 0f8be73..83f65bb 100644 --- a/example/app/models.py +++ b/example/app/models.py @@ -18,6 +18,7 @@ from django.utils.translation import gettext as _ from multiselectfield import MultiSelectField +from multiselectfield.db.fields import MultiSelectWithOtherField CATEGORY_CHOICES = ( (1, _('Handbooks and manuals by discipline')), @@ -35,15 +36,15 @@ ) TAGS_CHOICES = ( - ('sex', _('Sex')), # noqa: E241 - ('work', _('Work')), # noqa: E241 - ('happy', _('Happy')), # noqa: E241 - ('food', _('Food')), # noqa: E241 - ('field', _('Field')), # noqa: E241 - ('boring', _('Boring')), # noqa: E241 + ('sex', _('Sex')), # noqa: E241 + ('work', _('Work')), # noqa: E241 + ('happy', _('Happy')), # noqa: E241 + ('food', _('Food')), # noqa: E241 + ('field', _('Field')), # noqa: E241 + ('boring', _('Boring')), # noqa: E241 ('interesting', _('Interesting')), # noqa: E241 - ('huge', _('Huge')), # noqa: E241 - ('nice', _('Nice')), # noqa: E241 + ('huge', _('Huge')), # noqa: E241 + ('nice', _('Nice')), # noqa: E241 ) PROVINCES = ( @@ -59,7 +60,7 @@ PROVINCES_AND_STATES = ( (_("Canada - Provinces"), PROVINCES), - (_("USA - States"), STATES), # noqa: E241 + (_("USA - States"), STATES), # noqa: E241 ) @@ -76,6 +77,9 @@ class Book(models.Model): max_choices=2) chapters = MultiSelectField(choices=CHAPTER_CHOICES, default=ONE) + tabs_with_other = MultiSelectWithOtherField(choices=TAGS_CHOICES, other_max_length=100, + null=True, blank=True, min_choices=1, max_choices=2) + def __str__(self): return self.title diff --git a/example/app/test_msf.py b/example/app/test_msf.py index 4f4da13..24de0de 100644 --- a/example/app/test_msf.py +++ b/example/app/test_msf.py @@ -30,7 +30,6 @@ else: u = str - if VERSION < (1, 9): def get_field(model, name): return model._meta.get_field_by_name(name)[0] @@ -40,7 +39,6 @@ def get_field(model, name): class MultiSelectTestCase(TestCase): - fixtures = ['app_data.json'] maxDiff = 4000 @@ -69,6 +67,7 @@ def test_filter(self): def test_values_list(self): tag_list_list = Book.objects.all().values_list('tags', flat=True) categories_list_list = Book.objects.all().values_list('categories', flat=True) + tabs_with_other_list_list = Book.objects.all().values_list('tabs_with_other', flat=True) # Workaround for Django bug #9619 # https://code.djangoproject.com/ticket/9619 @@ -79,18 +78,27 @@ def test_values_list(self): if VERSION >= (1, 6) and VERSION < (1, 8): self.assertStringEqual(tag_list_list, [u('sex,work,happy')]) self.assertStringEqual(categories_list_list, [u('1,3,5')]) + self.assertStringEqual(tabs_with_other_list_list, [u('sex,other_option')]) else: self.assertListEqual(tag_list_list, [['sex', 'work', 'happy']]) self.assertListEqual(categories_list_list, [['1', '3', '5']]) + self.assertListEqual(tabs_with_other_list_list, [['sex', 'other_option']]) def test_form(self): - form_class = modelform_factory(Book, fields=('title', 'tags', 'categories')) - self.assertEqual(len(form_class.base_fields), 3) + form_class = modelform_factory(Book, fields=('title', 'tags', 'categories', 'tabs_with_other')) + self.assertEqual(len(form_class.base_fields), 4) form = form_class({'title': 'new book', - 'categories': '1,2'}) + 'categories': '1,2', 'tabs_with_other': 'sex,other_option'}) if form.is_valid(): form.save() + def test_form_invalid(self): + form_class = modelform_factory(Book, fields=('title', 'tags', 'categories', 'tabs_with_other')) + self.assertEqual(len(form_class.base_fields), 4) + form = form_class({'title': 'new book', + 'categories': '1,2', 'tabs_with_other': 'sex,work,other_option'}) + self.assertFalse(form.is_valid()) + def test_empty_update(self): book = Book.objects.get(id=1) self.assertEqual(book.get_chapters_list(), ["Chapter I"]) @@ -116,8 +124,12 @@ def test_object(self): book = Book.objects.get(id=1) self.assertEqual(book.get_tags_display(), 'Sex, Work, Happy') self.assertEqual(book.get_tags_list(), ['Sex', 'Work', 'Happy']) - self.assertEqual(book.get_categories_display(), 'Handbooks and manuals by discipline, Books of literary criticism, Books about literature') - self.assertEqual(book.get_categories_list(), ['Handbooks and manuals by discipline', 'Books of literary criticism', 'Books about literature']) + self.assertEqual(book.get_categories_display(), + 'Handbooks and manuals by discipline, Books of literary criticism, Books about literature') + self.assertEqual(book.get_categories_list(), + ['Handbooks and manuals by discipline', 'Books of literary criticism', + 'Books about literature']) + self.assertEqual(book.get_tabs_with_other_display(), 'Sex, other_option') self.assertEqual(book.get_tags_list(), book.get_tags_display().split(', ')) self.assertEqual(book.get_categories_list(), book.get_categories_display().split(', ')) @@ -143,10 +155,17 @@ def test_validate(self): except ValidationError: pass + try: + get_field(Book, 'tabs_with_other').clean(['sex', 'work', 'other_option'], book) + raise AssertionError() + except ValidationError: + pass + def test_serializer(self): book = Book.objects.get(id=1) self.assertEqual(get_field(Book, 'tags').value_to_string(book), 'sex,work,happy') self.assertEqual(get_field(Book, 'categories').value_to_string(book), '1,3,5') + self.assertEqual(get_field(Book, 'tabs_with_other').value_to_string(book), 'sex,|other_option') def test_flatchoices(self): self.assertEqual(get_field(Book, 'published_in').flatchoices, list(PROVINCES + STATES)) @@ -159,11 +178,12 @@ def test_named_groups_form(self): self.assertEqual(len(form_class.base_fields), 1) form = form_class(initial={'published_in': ['BC', 'AK']}) - expected_html = u("""

""") + expected_html = u( + """

""") actual_html = form.as_p() if (1, 11) <= VERSION: diff --git a/multiselectfield/db/fields.py b/multiselectfield/db/fields.py index 6e94bd2..70b0e5d 100644 --- a/multiselectfield/db/fields.py +++ b/multiselectfield/db/fields.py @@ -16,14 +16,13 @@ import sys +import six from django import VERSION - from django.db import models from django.utils.text import capfirst -from django.core import exceptions -from ..forms.fields import MultiSelectFormField, MinChoicesValidator, MaxChoicesValidator -from ..utils import get_max_length +from ..forms.fields import MultiSelectFormField, MinChoicesValidator, MaxChoicesValidator, MultiSelectWithOtherFormField +from ..utils import get_max_length, add_other_field_in_choices from ..validators import MaxValueMultiFieldValidator if sys.version_info < (3,): @@ -31,11 +30,18 @@ else: string_type = str +if VERSION >= (1, 8): + from django.core import exceptions, checks +else: + from django.core import exceptions + + # Code from six egg https://bitbucket.org/gutworth/six/src/a3641cb211cc360848f1e2dd92e9ae6cd1de55dd/six.py?at=default def add_metaclass(metaclass): """Class decorator for creating a class with a metaclass.""" + def wrapper(cls): orig_vars = cls.__dict__.copy() orig_vars.pop('__dict__', None) @@ -43,6 +49,7 @@ def wrapper(cls): for slots_var in orig_vars.get('__slots__', ()): orig_vars.pop(slots_var) return metaclass(cls.__name__, cls.__bases__, orig_vars) + return wrapper @@ -84,8 +91,11 @@ class MSFFlatchoices(list): # out) def __bool__(self): return False + __nonzero__ = __bool__ + return MSFFlatchoices(flat_choices) + flatchoices = property(_get_flatchoices) def get_choices_default(self): @@ -158,7 +168,7 @@ def to_python(self, value): return MSFList(choices, list(value)) return MSFList(choices, []) - if VERSION < (2, ): + if VERSION < (2,): def from_db_value(self, value, expression, connection, context): if value is None: return value @@ -189,6 +199,7 @@ def get_list(obj): def get_display(obj): return ", ".join(get_list(obj)) + get_display.short_description = self.verbose_name setattr(cls, 'get_%s_list' % self.name, get_list) @@ -198,8 +209,128 @@ def get_display(obj): if VERSION < (1, 8): MultiSelectField = add_metaclass(models.SubfieldBase)(MultiSelectField) + +class OtherMultiSelectFieldList(MSFList): + def __str__(self): + selected_choice_list = [self.choices.get(int(i)) if i.isdigit() else (self.choices.get(i) or i) for i in self] + return u', '.join([string_type(s) for s in selected_choice_list]) + + +class MultiSelectWithOtherField(MultiSelectField): + """ + This class is a Django Model field class that supports + multi select along with other option + The `other_max_length` parameter is required for this + Choice keys can not contain commas and other field can not contain + pipe character i.e. `|` + """ + + def __init__(self, other_max_length=None, *args, **kwargs): + self.other_max_length = other_max_length + self.other_delimiter = kwargs.get('other_delimiter', '|') + if kwargs.get('max_length') is None and other_max_length is not None: + choice_max_length = get_max_length(kwargs['choices'], kwargs.get('max_length')) + kwargs['max_length'] = choice_max_length + other_max_length + + if kwargs.get('choices'): + kwargs['choices'] = add_other_field_in_choices(kwargs['choices']) + + super(MultiSelectWithOtherField, self).__init__(*args, **kwargs) + + self.error_messages.update({ + 'invalid_char': 'value %s contains invalid character `{other_delimiter}`'.format( + other_delimiter=self.other_delimiter) + }) + + def get_prep_value(self, value): + selected_value = other_value = '' + choice_values = [choice[0] for choice in self.choices] + if value: + for val in value: + if val in choice_values: + selected_value += val + ',' + else: + other_value = val + + selected_value += self.other_delimiter + other_value + return selected_value + + def formfield(self, **kwargs): + defaults = { + 'required': not self.blank, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text, + 'choices': self.choices, + 'max_length': self.max_length, + 'max_choices': self.max_choices, + 'other_max_length': self.other_max_length + } + if self.has_default(): + defaults['initial'] = self.get_default() + defaults.update(kwargs) + return MultiSelectWithOtherFormField(**defaults) + + def validate(self, value, model_instance): + """ + This function is to validate the input values for multi select field, + however we are implementing field with support of other input filed + we are disabling validations to let other input text(other option) + pass to the database. + + :param value: list of all selected choice with other text. + :param model_instance: current model instance for with it is saving data. + :return: None + """ + for opt_select in value: + if self.other_delimiter in opt_select: + raise exceptions.ValidationError(self.error_messages['invalid_char'] % value) + + def to_python(self, value): + choices = dict(self.flatchoices) + if value: + if isinstance(value, list): + return value + elif isinstance(value, string_type): + choices_str = value.replace(self.other_delimiter, '') + selected_choices = [choice for choice in choices_str.split(',') if choice.strip()] + return OtherMultiSelectFieldList(choices, selected_choices) + elif isinstance(value, (set, dict)): + return MSFList(choices, list(value)) + return MSFList(choices, []) + + def _check_other_max_length_attribute(self, **kwargs): + if self.other_max_length is None: + return [ + checks.Error( + "MultiSelectWithOtherField must define a 'other_max_length' attribute.", + obj=self, + id='fields.E120', + ) + ] + elif not isinstance(self.other_max_length, six.integer_types) or self.other_max_length <= 0: + return [ + checks.Error( + "'other_max_length' must be a positive integer.", + obj=self, + id='fields.E121', + ) + ] + else: + return [] + + def check(self, **kwargs): + errors = super(MultiSelectWithOtherField, self).check(**kwargs) + errors.extend(self._check_other_max_length_attribute(**kwargs)) + return errors + + +if VERSION < (1, 8): + MultiSelectWithOtherField = add_metaclass(models.SubfieldBase)(MultiSelectWithOtherField) + try: from south.modelsinspector import add_introspection_rules - add_introspection_rules([], ['^multiselectfield\.db.fields\.MultiSelectField']) + + add_introspection_rules([], ['^multiselectfield\.db.fields\.MultiSelectField', + '^multiselectfield\.db.fields\.MultiSelectWithOtherField']) except ImportError: pass diff --git a/multiselectfield/forms/fields.py b/multiselectfield/forms/fields.py index 12d4133..702b6b2 100644 --- a/multiselectfield/forms/fields.py +++ b/multiselectfield/forms/fields.py @@ -15,8 +15,11 @@ # along with this programe. If not, see . from django import forms +from django.core.exceptions import ValidationError +from django.forms import CheckboxSelectMultiple +from django.utils.translation import ugettext_lazy as _ -from ..utils import get_max_length +from ..utils import get_max_length, add_other_field_in_choices from ..validators import MaxValueMultiFieldValidator, MinChoicesValidator, MaxChoicesValidator @@ -34,3 +37,94 @@ def __init__(self, *args, **kwargs): self.validators.append(MaxChoicesValidator(self.max_choices)) if self.min_choices is not None: self.validators.append(MinChoicesValidator(self.min_choices)) + + +def get_other_values(choices, value): + """ + This function to separate other's value from list of choices + :param choices: list of valid choices + :param value: list of selected choices including other's value + :return: list of other values. + """ + choice_values = [choice[0] for choice in choices] + other_values = [val for val in value if val not in choice_values] + return other_values + + +class CheckboxSelectMultipleWithOther(CheckboxSelectMultiple): + """ + Widget class to handle other value filed. + """ + other_choice = None + other_option_template_name = 'django/forms/widgets/text.html' + + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + option = super(CheckboxSelectMultipleWithOther, self).create_option(name, value, label, selected, index, + subindex, attrs) + + if value == 'other': + option.update({ + 'value': self.other_choice, + 'type': 'text', + 'template_name': self.other_option_template_name, + 'is_other': True + }) + + return option + + def optgroups(self, name, value, attrs=None): + """ + Return a list of optgroups for this widget. + """ + + other_values = get_other_values(self.choices, value) + + OTHER_CHOICE_INDEX = 0 + other_values = '' if not other_values else other_values.pop(OTHER_CHOICE_INDEX) + + self.other_choice = other_values + + return super(CheckboxSelectMultipleWithOther, self).optgroups(name, value, attrs) + + +class MultiSelectWithOtherFormField(MultiSelectFormField): + """ + Form field class to handle other text input field within the multiselect field + """ + widget = CheckboxSelectMultipleWithOther + + def __init__(self, other_max_length=None, *args, **kwargs): + if kwargs.get('choices'): + kwargs['choices'] = add_other_field_in_choices(kwargs['choices']) + + super(MultiSelectWithOtherFormField, self).__init__(*args, **kwargs) + + self.other_max_length = other_max_length + self.error_messages.update( + dict(invalid_length=_( + 'Other field value, maximum allowed length violation. Allowed limit is upto {other_max_length} characters.').format( + other_max_length=other_max_length))) + + def valid_value(self, value): + return len(value) <= self.other_max_length + + def validate(self, value): + """ + Validate that the input is a list or tuple. + """ + if self.required and not value: + raise ValidationError(self.error_messages['required'], code='required') + + if self.other_max_length is not None: + other_values = get_other_values(self.choices, value) + for val in other_values: + if not self.valid_value(val): + raise ValidationError( + self.error_messages['invalid_length'], + code='invalid_length', + params={'value': val}, + ) + + def clean(self, value): + value = [val for val in value if val not in self.empty_values] + return super(MultiSelectWithOtherFormField, self).clean(value) diff --git a/multiselectfield/utils.py b/multiselectfield/utils.py index 56c5d93..1e8c00a 100644 --- a/multiselectfield/utils.py +++ b/multiselectfield/utils.py @@ -16,7 +16,6 @@ import sys - if sys.version_info[0] == 2: string = basestring # noqa: F821 string_type = unicode # noqa: F821 @@ -32,3 +31,33 @@ def get_max_length(choices, max_length, default=200): else: return default return max_length + + +def add_other_field_in_choices(choices): + _choices = choices + + if isinstance(_choices, dict): + _choices = _choices.items() + + if 'other' not in [c for c, _ in _choices]: + if isinstance(choices, tuple): + return choices + (('other', 'Other'),) + if isinstance(choices, list): + return choices + [('other', 'Other')] + if isinstance(choices, dict): + choices['other'] = 'Other' + return choices + + return choices + + +def get_other_values(choices, value): + """ + This function to separate other's value from list of choices + :param choices: list of valid choices + :param value: list of selected choices including other's value + :return: list of other values. + """ + choice_values = [choice[0] for choice in choices] + other_values = [val for val in value if val not in choice_values] + return other_values diff --git a/setup.py b/setup.py index 4dced04..920f6bf 100644 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ def read(*rnames): 'flake8', ], install_requires=[ - 'django>=1.4', + 'django>=1.4', 'six' ], zip_safe=False, )