Skip to content

Commit 125bfb9

Browse files
committed
fix #109
1 parent 2fd2a65 commit 125bfb9

File tree

10 files changed

+244
-32
lines changed

10 files changed

+244
-32
lines changed

.github/workflows/test.yml

+7
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
actions: write
2828
# Service containers to run with `container-job`
2929
strategy:
30+
fail-fast: false
3031
matrix:
3132
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
3233
postgres-version: ['9.6', '12', 'latest']
@@ -158,6 +159,7 @@ jobs:
158159
TEST_DJANGO_VERSION: ${{ matrix.django-version }}
159160
TEST_DATABASE_VERSION: "sqlite"
160161
strategy:
162+
fail-fast: false
161163
matrix:
162164
python-version: [ '3.9', '3.13']
163165
django-version:
@@ -214,6 +216,7 @@ jobs:
214216
contents: read
215217
actions: write
216218
strategy:
219+
fail-fast: false
217220
matrix:
218221
python-version: [ '3.9', '3.13']
219222
mysql-version: ['5.7', 'latest']
@@ -321,6 +324,7 @@ jobs:
321324
TEST_DATABASE_VERSION: ${{ matrix.mariadb-version }}
322325

323326
strategy:
327+
fail-fast: false
324328
matrix:
325329
python-version: [ '3.9', '3.13']
326330
mysqlclient-version: ['1.4.3', '']
@@ -427,6 +431,7 @@ jobs:
427431
IGNORE_ORA_01843: True
428432
IGNORE_ORA_00932: True
429433
strategy:
434+
fail-fast: false
430435
matrix:
431436
python-version: ['3.9', '3.10', '3.12']
432437
django-version:
@@ -530,6 +535,7 @@ jobs:
530535
TEST_DJANGO_VERSION: ${{ matrix.django-version }}
531536
TEST_DATABASE_VERSION: "sqlite"
532537
strategy:
538+
fail-fast: false
533539
matrix:
534540
python-version: [ '3.9', '3.13']
535541
django-version:
@@ -590,6 +596,7 @@ jobs:
590596
TEST_DJANGO_VERSION: ${{ matrix.django-version }}
591597
TEST_DATABASE_VERSION: "sqlite"
592598
strategy:
599+
fail-fast: false
593600
matrix:
594601
python-version: [ '3.9', '3.13']
595602
django-version:

doc/source/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Change Log
77
v2.2.0 (2025-03-XX)
88
===================
99

10+
* Fixed `Enum types that resolve to primitives of str or int but that do not inherit from those types can result in validation errors. <https://github.com/bckohan/django-enum/issues/109>`_
1011
* Implemented `If default is not provided for flag fields it should be Flag(0). <https://github.com/bckohan/django-enum/issues/105>`_
1112
* Fixed `EnumFlagFields set empty values to Flag(0) when model field has null=True, default=None <https://github.com/bckohan/django-enum/issues/104>`_
1213
* Fixed `Large enum fields that inherit from binaryfield have editable=False by default <https://github.com/bckohan/django-enum/issues/103>`_

justfile

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
22
set unstable := true
3-
set script-interpreter := ['uv', 'run', '--script']
3+
4+
#set script-interpreter := ['uv', 'run', '--script']
5+
6+
set script-interpreter := ['python']
47

58
export PYTHONPATH := source_directory()
69

src/django_enum/fields.py

+40-14
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212

1313
from django import VERSION as django_version
1414
from django.core.exceptions import ValidationError
15-
from django.core.validators import DecimalValidator
15+
from django.core.validators import (
16+
DecimalValidator,
17+
MaxLengthValidator,
18+
MaxValueValidator,
19+
MinLengthValidator,
20+
MinValueValidator,
21+
)
1622
from django.db.models import (
1723
NOT_PROVIDED,
1824
BigIntegerField,
@@ -89,12 +95,17 @@ class EnumValidatorAdapter:
8995
"""
9096

9197
wrapped: Type
98+
allow_null: bool
9299

93-
def __init__(self, wrapped):
100+
def __init__(self, wrapped, allow_null):
94101
self.wrapped = wrapped
102+
self.allow_null = allow_null
95103

96104
def __call__(self, value):
97-
return self.wrapped(value.value if isinstance(value, Enum) else value)
105+
value = value.value if isinstance(value, Enum) else value
106+
if value is None and self.allow_null:
107+
return
108+
return self.wrapped(value)
98109

99110
def __eq__(self, other):
100111
return self.wrapped == other
@@ -792,6 +803,14 @@ def __init__(
792803
),
793804
)
794805
super().__init__(enum=enum, primitive=primitive, **kwargs)
806+
self.validators = [
807+
(
808+
EnumValidatorAdapter(validator, self.null) # type: ignore
809+
if isinstance(validator, (MinLengthValidator, MaxLengthValidator))
810+
else validator
811+
)
812+
for validator in self.validators
813+
]
795814

796815

797816
class EnumFloatField(EnumField[Type[float]], FloatField):
@@ -850,6 +869,8 @@ class IntEnumField(EnumField[Type[int]]):
850869
supporting enumerations with integer values.
851870
"""
852871

872+
validators: List[Any]
873+
853874
@property
854875
def bit_length(self):
855876
"""
@@ -875,6 +896,14 @@ def __init__(
875896
):
876897
self._bit_length_ = bit_length
877898
super().__init__(enum=enum, primitive=primitive, **kwargs)
899+
self.validators = [
900+
(
901+
EnumValidatorAdapter(validator, self.null) # type: ignore
902+
if isinstance(validator, (MinValueValidator, MaxValueValidator))
903+
else validator
904+
)
905+
for validator in self.validators
906+
]
878907

879908

880909
class EnumSmallIntegerField(IntEnumField, SmallIntegerField):
@@ -1082,6 +1111,14 @@ def __init__(
10821111
),
10831112
},
10841113
)
1114+
self.validators = [
1115+
(
1116+
EnumValidatorAdapter(validator, self.null) # type: ignore
1117+
if isinstance(validator, DecimalValidator)
1118+
else validator
1119+
)
1120+
for validator in self.validators
1121+
]
10851122

10861123
def to_python(self, value: Any) -> Union[Enum, Any]:
10871124
if not self.enum:
@@ -1090,17 +1127,6 @@ def to_python(self, value: Any) -> Union[Enum, Any]:
10901127
value = DecimalField.to_python(self, value)
10911128
return EnumField.to_python(self, value)
10921129

1093-
@cached_property
1094-
def validators(self):
1095-
return [
1096-
(
1097-
EnumValidatorAdapter(validator) # type: ignore
1098-
if isinstance(validator, DecimalValidator)
1099-
else validator
1100-
)
1101-
for validator in super().validators
1102-
]
1103-
11041130
def value_to_string(self, obj):
11051131
val = self.value_from_object(obj)
11061132
val = val.value if isinstance(val, Enum) else val

tests/djenum/admin.py

+2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
NullBlankFormTester,
88
NullableBlankFormTester,
99
Bug53Tester,
10+
NullableStrFormTester,
1011
)
1112

1213
admin.site.register(EnumTester)
1314
admin.site.register(NullBlankFormTester)
1415
admin.site.register(NullableBlankFormTester)
1516
admin.site.register(Bug53Tester)
17+
admin.site.register(NullableStrFormTester)
1618

1719

1820
class AdminDisplayBug35Admin(admin.ModelAdmin):

tests/djenum/enums.py

+9
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ def __str__(self):
6262
return self.name
6363

6464

65+
class NullableStrEnum(Enum):
66+
NONE = None
67+
STR1 = "str1"
68+
STR2 = "str2"
69+
70+
def __str__(self):
71+
return self.name
72+
73+
6574
class Constants(FloatChoices):
6675
PI = 3.14159265358979323846264338327950288, "Pi"
6776
e = 2.71828, "Euler's Number"

tests/djenum/models.py

+15
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
MultiWithNone,
2727
NegativeFlagEnum,
2828
NullableExternEnum,
29+
NullableStrEnum,
2930
PathEnum,
3031
PosIntEnum,
3132
PositiveFlagEnum,
@@ -358,6 +359,20 @@ class NullableBlankFormTester(models.Model):
358359
)
359360

360361

362+
class NullableStrFormTester(models.Model):
363+
required = EnumField(NullableStrEnum)
364+
required_default = EnumField(NullableStrEnum, default=NullableStrEnum.STR2)
365+
# this is allowed but will result in validation errors on form submission with null selected
366+
blank = EnumField(NullableStrEnum, null=False, blank=True)
367+
# this is allowed but you will not be able to submit nulls via a form
368+
# this is beyond the scope of django-enum - implement custom form logic to do this
369+
# nullable = EnumField(ExternEnum, null=True)
370+
blank_nullable = EnumField(NullableStrEnum, null=True, blank=True)
371+
blank_nullable_default = EnumField(
372+
NullableStrEnum, null=True, blank=True, default=NullableStrEnum.NONE
373+
)
374+
375+
361376
class Bug53Tester(models.Model):
362377
char_blank_null_true = EnumField(StrTestEnum, null=True, blank=True)
363378
char_blank_null_false = EnumField(StrTestEnum, null=False, blank=True)

tests/test_admin.py

+61-13
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@
1111
NullBlankFormTester,
1212
NullableBlankFormTester,
1313
Bug53Tester,
14+
NullableStrFormTester,
15+
)
16+
from tests.djenum.enums import (
17+
ExternEnum,
18+
NullableExternEnum,
19+
StrTestEnum,
20+
NullableStrEnum,
1421
)
15-
from tests.djenum.enums import ExternEnum, NullableExternEnum, StrTestEnum
1622
from playwright.sync_api import sync_playwright, Page, expect
1723
from django.urls import reverse
1824
from django.contrib.auth import get_user_model
@@ -77,6 +83,9 @@ class _GenericAdminFormTest(StaticLiveServerTestCase):
7783

7884
__test__ = False
7985

86+
def enum(self, field):
87+
return self.MODEL_CLASS._meta.get_field(field).enum
88+
8089
@property
8190
def changes(self) -> t.List[t.Dict[str, Enum]]:
8291
# must implement
@@ -148,18 +157,23 @@ def setUp(self):
148157
def set_form_value(
149158
self, field_name: str, value: t.Optional[t.Union[Enum, str]], flag=False
150159
):
151-
# should override this if needed
152-
if value is None and not flag:
153-
self.page.select_option(f"select[name='{field_name}']", "")
154-
elif flag:
155-
self.page.select_option(
156-
f"select[name='{field_name}']",
157-
[str(flag.value) for flag in decompose(value)],
158-
)
159-
else:
160-
self.page.select_option(
161-
f"select[name='{field_name}']", str(getattr(value, "value", value))
162-
)
160+
try:
161+
if value is None and None in self.enum(field_name):
162+
value = self.enum(field_name)(value)
163+
# should override this if needed
164+
if getattr(value, "value", value) is None and not flag:
165+
self.page.select_option(f"select[name='{field_name}']", "")
166+
elif flag:
167+
self.page.select_option(
168+
f"select[name='{field_name}']",
169+
[str(flag.value) for flag in decompose(value)],
170+
)
171+
else:
172+
self.page.select_option(
173+
f"select[name='{field_name}']", str(getattr(value, "value", value))
174+
)
175+
except Exception:
176+
self.page.pause()
163177

164178
def verify_changes(self, obj: Model, expected: t.Dict[str, t.Any]):
165179
count = 0
@@ -324,6 +338,7 @@ def changes(self) -> t.Dict[str, Enum]:
324338
class TestNullableBlankAdminBehavior(_GenericAdminFormTest):
325339
MODEL_CLASS = NullableBlankFormTester
326340
__test__ = True
341+
HEADLESS = True
327342

328343
@property
329344
def changes(self) -> t.Dict[str, Enum]:
@@ -353,6 +368,39 @@ def changes(self) -> t.Dict[str, Enum]:
353368
]
354369

355370

371+
class TestNullableStrAdminBehavior(_GenericAdminFormTest):
372+
MODEL_CLASS = NullableStrFormTester
373+
__test__ = True
374+
HEADLESS = True
375+
376+
@property
377+
def changes(self) -> t.Dict[str, Enum]:
378+
return [
379+
{"required": NullableStrEnum.STR1, "blank": NullableStrEnum.STR2},
380+
{
381+
"required": NullableStrEnum.STR2,
382+
"required_default": NullableStrEnum.STR1,
383+
"blank": NullableStrEnum.STR2,
384+
"blank_nullable": None,
385+
"blank_nullable_default": None,
386+
},
387+
{
388+
"required": NullableStrEnum.STR1,
389+
"required_default": NullableStrEnum.STR2,
390+
"blank": NullableStrEnum.STR2,
391+
"blank_nullable": NullableStrEnum.STR1,
392+
"blank_nullable_default": NullableStrEnum.NONE,
393+
},
394+
{
395+
"required": NullableStrEnum.STR2,
396+
"required_default": NullableStrEnum.STR2,
397+
"blank": None,
398+
"blank_nullable": "",
399+
"blank_nullable_default": "",
400+
},
401+
]
402+
403+
356404
class TestBug53AdminBehavior(_GenericAdminFormTest):
357405
MODEL_CLASS = Bug53Tester
358406
__test__ = True

0 commit comments

Comments
 (0)