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("""
- Canada - Provinces
\n"""
- """- USA - States
""")
+ expected_html = u(
+ """
- Canada - Provinces
\n"""
+ """- USA - States
""")
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,
)