Skip to content

Commit ff0108c

Browse files
committed
fix #24
1 parent 4f2f044 commit ff0108c

File tree

4 files changed

+240
-7
lines changed

4 files changed

+240
-7
lines changed

doc/source/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ v2.2.0 (2025-03-XX)
1818
* Implemented `Upgrade to enum-properties >=2.2 <https://github.com/bckohan/django-enum/issues/95>`_
1919
* Implemented `Move form imports to locally scoped imports where needed in fields.py <https://github.com/bckohan/django-enum/issues/79>`_
2020
* Implemented `Reorganize documentation using diataxis <https://github.com/bckohan/django-enum/issues/72>`_
21+
* Implemented `Provide an EnumMultipleChoiceField <https://github.com/bckohan/django-enum/issues/72>`_
2122

2223
v2.1.0 (2025-02-24)
2324
===================

src/django_enum/forms.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"FlagSelectMultiple",
3030
"ChoiceFieldMixin",
3131
"EnumChoiceField",
32+
"EnumMultipleChoiceField",
3233
"EnumFlagField",
3334
]
3435

@@ -322,9 +323,21 @@ def validate(self, value):
322323

323324
class EnumChoiceField(ChoiceFieldMixin, TypedChoiceField): # type: ignore
324325
"""
325-
The default ``ChoiceField`` will only accept the base enumeration values.
326-
Use this field on forms to accept any value mappable to an enumeration
327-
including any labels or symmetric properties.
326+
The default :class:`~django.forms.fields.ChoiceField` will only accept the base
327+
enumeration values. Use this field on forms to accept any value mappable to an
328+
enumeration including any labels, symmetric properties, of values accepted in
329+
:meth:`~enum.Enum._missing_`.
330+
"""
331+
332+
333+
class EnumMultipleChoiceField( # type: ignore
334+
ChoiceFieldMixin, TypedMultipleChoiceField
335+
):
336+
"""
337+
The default :class:`~django.forms.fields.MultipleChoiceField` will only accept the
338+
base enumeration values. Use this field on forms to accept multiple values mappable
339+
to an enumeration including any labels, symmetric properties, of values accepted in
340+
:meth:`~enum.Enum._missing_`.
328341
"""
329342

330343

tests/djenum/forms.py

+44-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,51 @@
1-
from django.forms import ModelForm
2-
1+
from django.forms import ModelForm, Form
2+
from django_enum.forms import EnumMultipleChoiceField
33
from tests.djenum.models import EnumTester
4+
from tests.djenum.enums import (
5+
SmallPosIntEnum,
6+
SmallIntEnum,
7+
PosIntEnum,
8+
IntEnum,
9+
BigPosIntEnum,
10+
BigIntEnum,
11+
Constants,
12+
TextEnum,
13+
ExternEnum,
14+
DJIntEnum,
15+
DJTextEnum,
16+
DateEnum,
17+
DateTimeEnum,
18+
DecimalEnum,
19+
TimeEnum,
20+
DurationEnum,
21+
)
422

523

624
class EnumTesterForm(ModelForm):
725
class Meta:
826
model = EnumTester
927
fields = "__all__"
28+
29+
30+
class EnumTesterMultipleChoiceForm(Form):
31+
small_pos_int = EnumMultipleChoiceField(SmallPosIntEnum)
32+
small_int = EnumMultipleChoiceField(SmallIntEnum)
33+
pos_int = EnumMultipleChoiceField(PosIntEnum)
34+
int = EnumMultipleChoiceField(IntEnum)
35+
big_pos_int = EnumMultipleChoiceField(BigPosIntEnum)
36+
big_int = EnumMultipleChoiceField(BigIntEnum)
37+
constant = EnumMultipleChoiceField(Constants)
38+
text = EnumMultipleChoiceField(TextEnum)
39+
extern = EnumMultipleChoiceField(ExternEnum)
40+
41+
# Non-strict
42+
non_strict_int = EnumMultipleChoiceField(SmallPosIntEnum, strict=False)
43+
non_strict_text = EnumMultipleChoiceField(TextEnum, strict=False)
44+
no_coerce = EnumMultipleChoiceField(SmallPosIntEnum, strict=False)
45+
46+
# eccentric enums
47+
date_enum = EnumMultipleChoiceField(DateEnum)
48+
datetime_enum = EnumMultipleChoiceField(DateTimeEnum)
49+
time_enum = EnumMultipleChoiceField(TimeEnum)
50+
duration_enum = EnumMultipleChoiceField(DurationEnum)
51+
decimal_enum = EnumMultipleChoiceField(DecimalEnum)

tests/test_forms.py

+179-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from django.test import TestCase
2+
import pytest
23
import django
4+
from django.db import connection
35
from tests.utils import EnumTypeMixin
46
from tests.djenum.models import EnumTester, Bug53Tester, NullableStrEnum
5-
from tests.djenum.forms import EnumTesterForm
7+
from tests.djenum.forms import EnumTesterForm, EnumTesterMultipleChoiceForm
68
from django.forms import Form, ModelForm
7-
from django_enum.forms import EnumChoiceField
9+
from django_enum.forms import EnumChoiceField, EnumMultipleChoiceField
810
from django.core.exceptions import ValidationError
911
from datetime import date, datetime, timedelta, time
1012
from decimal import Decimal
@@ -198,6 +200,10 @@ class Meta:
198200
self.assertEqual(form.cleaned_data["required_default"], ExternEnum.ONE)
199201
self.assertIsInstance(form.base_fields["required"], EnumChoiceField)
200202

203+
@pytest.mark.skipif(
204+
connection.vendor == "oracle",
205+
reason="Null/blank form behavior on oracle broken",
206+
)
201207
def test_nullable_blank_tester_form(self):
202208
from tests.djenum.models import NullableBlankFormTester
203209
from tests.djenum.enums import NullableExternEnum
@@ -247,6 +253,10 @@ class Meta:
247253
self.assertEqual(form.cleaned_data["required_default"], NullableExternEnum.ONE)
248254
self.assertIsInstance(form.base_fields["required"], EnumChoiceField)
249255

256+
@pytest.mark.skipif(
257+
connection.vendor == "oracle",
258+
reason="Null/blank form behavior on oracle broken",
259+
)
250260
def test_nullable_str_tester_form(self):
251261
from tests.djenum.models import NullableStrFormTester
252262
from tests.djenum.enums import NullableStrEnum
@@ -468,3 +478,170 @@ def test_non_strict_field(self):
468478
form["non_strict_int"].field.to_python(form["non_strict_int"].value()),
469479
self.enum_primitive("non_strict_int"),
470480
)
481+
482+
483+
class TestEnumMultipleChoiceFormField(EnumTypeMixin, TestCase):
484+
MODEL_CLASS = EnumTester
485+
FORM_CLASS = EnumTesterMultipleChoiceForm
486+
form_type = None
487+
488+
@property
489+
def model_params(self):
490+
return {
491+
"small_pos_int": [0],
492+
"small_int": [self.SmallIntEnum.VAL2, self.SmallIntEnum.VALn1],
493+
"pos_int": [2147483647, self.PosIntEnum.VAL3],
494+
"int": [self.IntEnum.VALn1],
495+
"big_pos_int": [2, self.BigPosIntEnum.VAL3],
496+
"big_int": [self.BigIntEnum.VAL0],
497+
"constant": [2.71828, self.Constants.GOLDEN_RATIO],
498+
"text": [self.TextEnum.VALUE3, self.TextEnum.VALUE2],
499+
"extern": [self.ExternEnum.THREE],
500+
"date_enum": [self.DateEnum.BRIAN, date(1989, 7, 27)],
501+
"datetime_enum": [self.DateTimeEnum.ST_HELENS, self.DateTimeEnum.ST_HELENS],
502+
"duration_enum": [self.DurationEnum.FORTNIGHT],
503+
"time_enum": [self.TimeEnum.COB, self.TimeEnum.LUNCH],
504+
"decimal_enum": [self.DecimalEnum.ONE],
505+
"non_strict_int": [self.SmallPosIntEnum.VAL2],
506+
"non_strict_text": ["arbitrary", "A" * 13],
507+
"no_coerce": [self.SmallPosIntEnum.VAL1],
508+
}
509+
510+
@property
511+
def bad_values(self):
512+
return {
513+
"small_pos_int": [4.1],
514+
"small_int": ["Value 12"],
515+
"pos_int": [5.3],
516+
"int": [10],
517+
"big_pos_int": ["-12"],
518+
"big_int": ["-12"],
519+
"constant": [2.7],
520+
"text": ["143 emma"],
521+
"date_enum": ["20159-01-01"],
522+
"datetime_enum": ["AAAA-01-01 00:00:00"],
523+
"duration_enum": ["1 elephant"],
524+
"time_enum": ["2.a"],
525+
"decimal_enum": ["alpha"],
526+
"extern": [6],
527+
"non_strict_int": ["Not an int"],
528+
"non_strict_text": [],
529+
"no_coerce": ["Value 0"],
530+
}
531+
532+
from json import encoder
533+
534+
def verify_field(self, form, field, values):
535+
# this doesnt work with coerce=False fields
536+
for idx, value in enumerate(values):
537+
if self.MODEL_CLASS._meta.get_field(field).strict:
538+
self.assertEqual(
539+
form[field].value()[idx], self.enum_type(field)(value).value
540+
)
541+
self.assertIsInstance(
542+
form[field].field.to_python(form[field].value())[idx],
543+
self.enum_type(field),
544+
)
545+
546+
def test_initial(self):
547+
form = self.FORM_CLASS(initial=self.model_params)
548+
for field, values in self.model_params.items():
549+
self.verify_field(form, field, values)
550+
551+
def test_data(self):
552+
form = self.FORM_CLASS(data=self.model_params)
553+
form.full_clean()
554+
self.assertTrue(form.is_valid())
555+
for field, values in self.model_params.items():
556+
self.verify_field(form, field, values)
557+
558+
def test_error(self):
559+
for field, bad_value in self.bad_values.items():
560+
form = self.FORM_CLASS(data={**self.model_params, field: bad_value})
561+
form.full_clean()
562+
self.assertFalse(form.is_valid(), f"{field}={bad_value}: {form.errors}")
563+
self.assertTrue(field in form.errors)
564+
565+
form = self.FORM_CLASS(data=self.bad_values)
566+
form.full_clean()
567+
self.assertFalse(form.is_valid())
568+
for field in self.bad_values.keys():
569+
self.assertTrue(field in form.errors)
570+
571+
def test_field_validation(self):
572+
for enum_field, bad_value in [
573+
(EnumMultipleChoiceField(self.SmallPosIntEnum), 4.1),
574+
(EnumMultipleChoiceField(self.SmallIntEnum), 123123123),
575+
(EnumMultipleChoiceField(self.PosIntEnum), -1),
576+
(EnumMultipleChoiceField(self.IntEnum), "63"),
577+
(EnumMultipleChoiceField(self.BigPosIntEnum), None),
578+
(EnumMultipleChoiceField(self.BigIntEnum), ""),
579+
(EnumMultipleChoiceField(self.Constants), "y"),
580+
(EnumMultipleChoiceField(self.TextEnum), 42),
581+
(EnumMultipleChoiceField(self.DateEnum), "20159-01-01"),
582+
(EnumMultipleChoiceField(self.DateTimeEnum), "AAAA-01-01 00:00:00"),
583+
(EnumMultipleChoiceField(self.DurationEnum), "1 elephant"),
584+
(EnumMultipleChoiceField(self.TimeEnum), "2.a"),
585+
(EnumMultipleChoiceField(self.DecimalEnum), "alpha"),
586+
(EnumMultipleChoiceField(self.ExternEnum), 0),
587+
(EnumMultipleChoiceField(self.DJIntEnum), "5.3"),
588+
(EnumMultipleChoiceField(self.DJTextEnum), 12),
589+
(EnumMultipleChoiceField(self.SmallPosIntEnum, strict=False), "not an int"),
590+
]:
591+
self.assertRaises(ValidationError, enum_field.validate, [bad_value])
592+
593+
for enum_field, bad_value in [
594+
(EnumMultipleChoiceField(self.SmallPosIntEnum, strict=False), 4),
595+
(EnumMultipleChoiceField(self.SmallIntEnum, strict=False), 123123123),
596+
(EnumMultipleChoiceField(self.PosIntEnum, strict=False), -1),
597+
(EnumMultipleChoiceField(self.IntEnum, strict=False), "63"),
598+
(EnumMultipleChoiceField(self.BigPosIntEnum, strict=False), 18),
599+
(EnumMultipleChoiceField(self.BigIntEnum, strict=False), "-8"),
600+
(EnumMultipleChoiceField(self.Constants, strict=False), "1.976"),
601+
(EnumMultipleChoiceField(self.TextEnum, strict=False), 42),
602+
(EnumMultipleChoiceField(self.ExternEnum, strict=False), 0),
603+
(EnumMultipleChoiceField(self.DJIntEnum, strict=False), "5"),
604+
(EnumMultipleChoiceField(self.DJTextEnum, strict=False), 12),
605+
(EnumMultipleChoiceField(self.SmallPosIntEnum, strict=False), "12"),
606+
(
607+
EnumMultipleChoiceField(self.DateEnum, strict=False),
608+
date(year=2015, month=1, day=1),
609+
),
610+
(
611+
EnumMultipleChoiceField(self.DateTimeEnum, strict=False),
612+
datetime(year=2014, month=1, day=1, hour=0, minute=0, second=0),
613+
),
614+
(
615+
EnumMultipleChoiceField(self.DurationEnum, strict=False),
616+
timedelta(seconds=15),
617+
),
618+
(
619+
EnumMultipleChoiceField(self.TimeEnum, strict=False),
620+
time(hour=2, minute=0, second=0),
621+
),
622+
(EnumMultipleChoiceField(self.DecimalEnum, strict=False), Decimal("0.5")),
623+
]:
624+
try:
625+
enum_field.clean([bad_value])
626+
except ValidationError: # pragma: no cover
627+
self.fail(
628+
f"non-strict choice field for {enum_field.enum} "
629+
f"raised ValidationError on {bad_value} during clean"
630+
)
631+
632+
def test_non_strict_field(self):
633+
form = self.FORM_CLASS(data={**self.model_params, "non_strict_int": [200, 203]})
634+
form.full_clean()
635+
self.assertTrue(form.is_valid())
636+
for idx in range(0, 2):
637+
self.assertIsInstance(
638+
form["non_strict_int"].value()[idx],
639+
self.enum_primitive("non_strict_int"),
640+
)
641+
self.assertIsInstance(
642+
form["non_strict_int"].field.to_python(form["non_strict_int"].value())[
643+
idx
644+
],
645+
self.enum_primitive("non_strict_int"),
646+
)
647+
self.assertEqual(form["non_strict_int"].value(), [200, 203])

0 commit comments

Comments
 (0)