-
-
Save danni/f55c4ce19598b2b345ef to your computer and use it in GitHub Desktop.
from django import forms | |
from django.contrib.postgres.fields import ArrayField | |
class ChoiceArrayField(ArrayField): | |
""" | |
A field that allows us to store an array of choices. | |
Uses Django 1.9's postgres ArrayField | |
and a MultipleChoiceField for its formfield. | |
Usage: | |
choices = ChoiceArrayField(models.CharField(max_length=..., | |
choices=(...,)), | |
default=[...]) | |
""" | |
def formfield(self, **kwargs): | |
defaults = { | |
'form_class': forms.MultipleChoiceField, | |
'choices': self.base_field.choices, | |
} | |
defaults.update(kwargs) | |
# Skip our parent's formfield implementation completely as we don't | |
# care for it. | |
# pylint:disable=bad-super-call | |
return super(ArrayField, self).formfield(**defaults) |
Bless you! after slamming my head against a wall for nearly an entire day, your snippet gave me a breakthrough! Now, if you could add an example of using it with a field widget you found useful, this here backend guy thrown into doing frontend code would be eternally grateful!
Thanks a lot @danni! You're the hero.
@danni That's kind of problematic with the base_field being anything but a CharField. Wouldn't it be better to use a TypedMultipleChoiceField thus?
def formfield(self, **kwargs):
defaults = {
'form_class': forms.TypedMultipleChoiceField,
'choices': self.base_field.choices,
'coerce': self.base_field.to_python
}
defaults.update(kwargs)
# Skip our parent's formfield implementation completely as we don't
# care for it.
# pylint:disable=bad-super-call
return super(ArrayField, self).formfield(**defaults)
Actually starting on Django > 1.10.2 the custom field also needs a custom widget like below. Otherwise an empty selection won't be written back to the model.
from django import forms
from django.contrib.postgres.fields import ArrayField
from django.forms import SelectMultiple
class ArraySelectMultiple(SelectMultiple):
def value_omitted_from_data(self, data, files, name):
return False
class ChoiceArrayField(ArrayField):
def formfield(self, **kwargs):
defaults = {
'form_class': forms.TypedMultipleChoiceField,
'choices': self.base_field.choices,
'coerce': self.base_field.to_python,
'widget': ArraySelectMultiple
}
defaults.update(kwargs)
# Skip our parent's formfield implementation completely as we don't care for it.
# pylint:disable=bad-super-call
return super(ArrayField, self).formfield(**defaults)
Hi @danni, @Proper-Job
Using Django 1.11, this is what I have:
from django.contrib.postgres.fields import ArrayField
from django.forms import SelectMultiple
class FacilitiesArrayField(ArrayField):
def formfield(self, **kwargs):
defaults = {
'form_class': forms.MultipleChoiceField,
'choices': self.base_field.choices,
'widget': forms.CheckboxSelectMultiple,
}
defaults.update(kwargs)
return super(ArrayField, self).formfield(**defaults)
I've succeeded entering the data (checked via ./manage.py shell
), but when take a look back my django-admin, none checked..
Any ideas?
Good snippet!
Thanks very much for this @danni & @Proper-Job.
If you are using base field IntegerField or other than CharField, you will need to convert values in list to python.
class ChoiceArrayField(ArrayField):
"""
A field that allows us to store an array of choices.
Uses Django's Postgres ArrayField
and a MultipleChoiceField for its formfield.
"""
def formfield(self, **kwargs):
defaults = {
'form_class': forms.MultipleChoiceField,
'choices': self.base_field.choices,
'widget': ArraySelectMultiple,
}
defaults.update(kwargs)
return super(ArrayField, self).formfield(**defaults)
def to_python(self, value):
res = super().to_python(value)
if isinstance(res, list):
value = [self.base_field.to_python(val) for val in res]
return value
Thanks for this snippet.
Thanks for this!
I had to overwrite the validation as it was complaining for multiple values:
django.core.exceptions.ValidationError: {'field_name': ["Value ['ONE, 'TWO', 'THREE'] is not a valid choice."]}
class ChoiceArrayField(ArrayField):
"""
A postgres ArrayField that supports the choices property.
Ref. https://gist.github.com/danni/f55c4ce19598b2b345ef.
"""
def formfield(self, **kwargs):
defaults = {
"form_class": forms.MultipleChoiceField,
"choices": self.base_field.choices,
}
defaults.update(kwargs)
return super(ArrayField, self).formfield(**defaults)
def to_python(self, value):
res = super().to_python(value)
if isinstance(res, list):
value = [self.base_field.to_python(val) for val in res]
return value
def validate(self, value, model_instance):
if not self.editable:
# Skip validation for non-editable fields.
return
if self.choices is not None and value not in self.empty_values:
if set(value).issubset({option_key for option_key, _ in self.choices}):
return
raise exceptions.ValidationError(
self.error_messages["invalid_choice"],
code="invalid_choice",
params={"value": value},
)
if value is None and not self.null:
raise exceptions.ValidationError(self.error_messages["null"], code="null")
if not self.blank and value in self.empty_values:
raise exceptions.ValidationError(self.error_messages["blank"], code="blank")
give this guy an Oscar right now! such an elegant solution, thank you soooo much!!!
Thank you @pcraciunoiu
@pcraciunoiu , I am getting:
TypeError
__init__() got an unexpected keyword argument 'base_field'
Relevant traceback:
/usr/local/lib/python3.8/site-packages/django/forms/fields.py, line 772, in __init__
class ChoiceField(Field):
widget = Select
default_error_messages = {
'invalid_choice': _('Select a valid choice. %(value)s is not one of the available choices.'),
}
def __init__(self, *, choices=(), **kwargs):
super().__init__(**kwargs) …
self.choices = choices
def __deepcopy__(self, memo):
result = super().__deepcopy__(memo)
result._choices = copy.deepcopy(self._choices, memo)
return result
@niccolomineo our project still uses this exactly as I pasted it, and it works on Django 3.1.7 with postgres. Are you sure you're on a version that's valid? Should be at least Django 2.2 I think.
Also this is a model field, not a form field. You might be using it in the wrong place.
Yep, I am using it as an ArrayField
replacement. I am on Django 3.2.
@pcraciunoiu Outstanding solution. Works on Django 3.2/Python 3.8.
Why doesn't this work in Django 4? I keep getting AttributeError: 'ChoiceArrayField' object has no attribute 'get_bound_field'
@niccolomineo I got the same error on Django 3.2 and Python 3.9. In my model definition I had
my_field = ChoiceArrayField(
base_field=models.CharField(choices=my_choices, max_length=3),
default=list,
blank=True,
)
I got errors like you for base_field
and also max_length
(?).
Without diving too deep into the issue, I did a dirty fix by overriding forms.MultipleChoiceField.__init__
like so
class _MultipleChoiceField(forms.MultipleChoiceField):
def __init__(self, *args, **kwargs):
kwargs.pop("base_field", None)
kwargs.pop("max_length", None)
super().__init__(*args, **kwargs)
and use that as form_class
in the implementation from @pcraciunoiu.
It solved the issue for me.
Building on all the previous answers, this is my setup working with Django 4 (4.0.3
).
Form field, model field, choices and model (models.py
)
from django.contrib.postgres.fields import ArrayField
from django.db.models import Model
from django.db.models.enums import TextChoices
from django.db.models.fields import CharField
from django.forms.fields import MultipleChoiceField
# Contribution by @cbows
class _MultipleChoiceField(MultipleChoiceField):
def __init__(self, *args, **kwargs):
kwargs.pop("base_field", None)
kwargs.pop("max_length", None)
super().__init__(*args, **kwargs)
# Original contribution by @danni
# slightly rewrited to match Django writing code style
class ChoiceArrayField(ArrayField):
def formfield(self, **kwargs):
return super().formfield(**{"form_class": _MultipleChoiceField,
"choices": self.base_field.choices,
**kwargs})
class MyOption(TextChoices):
OPTION1 = 'OPTION1', "Option 1"
OPTION2 = 'OPTION2', "Option 2"
OPTION3 = 'OPTION3', "Option 3"
class MyModel(Model):
# All your fields...
options = ChoiceArrayField(CharField(max_length=24, choices=MyOption.choices), default=list)
Django admin configuration (admin.py
)
from django.contrib.admin import register
from django.contrib.admin.options import ModelAdmin
from django.forms.widgets import CheckboxSelectMultiple
from yourdjangoapplication.admin import site
from .models import MyModel, ChoiceArrayField
@register(MyModel, site=site)
class MyModelAdmin(ModelAdmin):
fields = (
# all your fields...
"options", )
# The default widget is a <select multiple>;
# use this configuration for a group of checkboxes.
formfield_overrides = {
ChoiceArrayField: {'widget': CheckboxSelectMultiple}
}
After combining all the previous answers, below works for me, Django 4.2, Python 3.11
from django import forms
from django.contrib.postgres.fields import ArrayField
class _TypedMultipleChoiceField(forms.TypedMultipleChoiceField):
def __init__(self, *args, **kwargs):
kwargs.pop("base_field", None)
kwargs.pop("max_length", None)
super().__init__(*args, **kwargs)
class ChoiceArrayField(ArrayField):
"""
A field that allows us to store an array of choices.
Uses Django 4.2's postgres ArrayField
and a TypeMultipleChoiceField for its formfield.
Usage:
choices = ChoiceArrayField(
models.CharField(max_length=..., choices=(...,)), blank=[...], default=[...]
)
"""
def formfield(self, **kwargs):
defaults = {
'form_class': _TypedMultipleChoiceField,
'choices': self.base_field.choices,
'coerce': self.base_field.to_python,
}
defaults.update(kwargs)
# Skip our parent's formfield implementation completely as we don't care for it.
# pylint:disable=bad-super-call
return super().formfield(**defaults)
Thanks everybody. using @anyidea 's solution. works nicely
Thanks this solution works very well
Excellent snippet, thanks!