Skip to content

Commit e564666

Browse files
authored
Merge pull request #27 from craigds/cds-fix-boolean-validation-error
Fix ValidationError on BooleanField with None value
2 parents d2d4b91 + 3964821 commit e564666

File tree

2 files changed

+61
-3
lines changed

2 files changed

+61
-3
lines changed

fieldsignals/signals.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import TYPE_CHECKING, Any
55

66
from django.apps import apps
7-
from django.core.exceptions import AppRegistryNotReady
7+
from django.core.exceptions import AppRegistryNotReady, ValidationError
88
from django.db.models import signals as _signals
99
from django.db.models.fields.related import ForeignObjectRel
1010
from django.dispatch import Signal
@@ -169,7 +169,11 @@ def get_and_update_changed_fields(
169169
if field.attname in deferred_fields:
170170
continue
171171
# using value_from_object instead of getattr() means we don't traverse foreignkeys
172-
new_value = field.to_python(field.value_from_object(instance))
172+
try:
173+
new_value = field.to_python(field.value_from_object(instance))
174+
except ValidationError:
175+
# Skip fields with invalid values (e.g., BooleanField with None)
176+
continue
173177
old_value = originals.get(field.name, None)
174178
if old_value != new_value:
175179
if not isinstance(new_value, IMMUTABLE_TYPES_WHITELIST):

fieldsignals/tests/test_signals.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import pytest
1010
from django.apps import apps
11-
from django.core.exceptions import AppRegistryNotReady
11+
from django.core.exceptions import AppRegistryNotReady, ValidationError
1212
from django.db.models.fields.related import OneToOneRel
1313
from django.db.models.signals import post_init, post_save, pre_save
1414
from django.utils.dateparse import parse_datetime
@@ -69,6 +69,13 @@ def to_python(self, value: Any) -> datetime.datetime | None:
6969
return parse_datetime(value)
7070

7171

72+
class BooleanField(Field):
73+
def to_python(self, value: Any) -> bool:
74+
if value is None:
75+
raise ValidationError('"None" value must be either True or False.')
76+
return bool(value)
77+
78+
7279
class FakeModel:
7380
a_key = "a value"
7481
another = "something else"
@@ -104,6 +111,22 @@ def get_deferred_fields(self) -> set[str]:
104111
return {"b"}
105112

106113

114+
class BooleanFieldModel:
115+
is_active: bool | None = None
116+
name: str = "test"
117+
118+
class _meta:
119+
@staticmethod
120+
def get_fields() -> list[Field]:
121+
return [
122+
BooleanField("is_active"),
123+
Field("name"),
124+
]
125+
126+
def get_deferred_fields(self) -> set[str]:
127+
return set()
128+
129+
107130
class MockOneToOneRel(OneToOneRel):
108131
def __init__(self, name: str) -> None:
109132
self.name = name
@@ -169,6 +192,37 @@ def test_deferred_fields(self) -> None:
169192

170193
assert list(obj._fieldsignals_originals.values()) == [{"a": 1}]
171194

195+
def test_boolean_field_with_none(self) -> None:
196+
"""
197+
BooleanField.to_python(None) raises ValidationError.
198+
Ensure post_init doesn't crash when field value is None.
199+
"""
200+
with must_be_called(False) as test_func:
201+
pre_save_changed.connect(test_func, sender=BooleanFieldModel)
202+
203+
obj = BooleanFieldModel()
204+
# Should not raise ValidationError
205+
post_init.send(instance=obj, sender=BooleanFieldModel)
206+
207+
# Only 'name' field should be tracked (is_active skipped due to ValidationError)
208+
assert list(obj._fieldsignals_originals.values()) == [{"name": "test"}]
209+
210+
def test_boolean_field_transition_to_valid(self) -> None:
211+
"""
212+
When a BooleanField transitions from invalid (None) to valid (True/False),
213+
the signal should fire with old_value=None.
214+
"""
215+
with must_be_called(True) as func:
216+
pre_save_changed.connect(func, sender=BooleanFieldModel, fields=["is_active"])
217+
218+
obj = BooleanFieldModel()
219+
post_init.send(instance=obj, sender=BooleanFieldModel)
220+
221+
obj.is_active = True
222+
pre_save.send(instance=obj, sender=BooleanFieldModel)
223+
224+
assert func.kwargs["changed_fields"] == {"is_active": (None, True)}
225+
172226

173227
class TestPostSave:
174228
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)