Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions fieldsignals/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any

from django.apps import apps
from django.core.exceptions import AppRegistryNotReady
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.db.models import signals as _signals
from django.db.models.fields.related import ForeignObjectRel
from django.dispatch import Signal
Expand Down Expand Up @@ -169,7 +169,11 @@ def get_and_update_changed_fields(
if field.attname in deferred_fields:
continue
# using value_from_object instead of getattr() means we don't traverse foreignkeys
new_value = field.to_python(field.value_from_object(instance))
try:
new_value = field.to_python(field.value_from_object(instance))
except ValidationError:
# Skip fields with invalid values (e.g., BooleanField with None)
continue
old_value = originals.get(field.name, None)
if old_value != new_value:
if not isinstance(new_value, IMMUTABLE_TYPES_WHITELIST):
Expand Down
56 changes: 55 additions & 1 deletion fieldsignals/tests/test_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import pytest
from django.apps import apps
from django.core.exceptions import AppRegistryNotReady
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.db.models.fields.related import OneToOneRel
from django.db.models.signals import post_init, post_save, pre_save
from django.utils.dateparse import parse_datetime
Expand Down Expand Up @@ -69,6 +69,13 @@ def to_python(self, value: Any) -> datetime.datetime | None:
return parse_datetime(value)


class BooleanField(Field):
def to_python(self, value: Any) -> bool:
if value is None:
raise ValidationError('"None" value must be either True or False.')
return bool(value)


class FakeModel:
a_key = "a value"
another = "something else"
Expand Down Expand Up @@ -104,6 +111,22 @@ def get_deferred_fields(self) -> set[str]:
return {"b"}


class BooleanFieldModel:
is_active: bool | None = None
name: str = "test"

class _meta:
@staticmethod
def get_fields() -> list[Field]:
return [
BooleanField("is_active"),
Field("name"),
]

def get_deferred_fields(self) -> set[str]:
return set()


class MockOneToOneRel(OneToOneRel):
def __init__(self, name: str) -> None:
self.name = name
Expand Down Expand Up @@ -169,6 +192,37 @@ def test_deferred_fields(self) -> None:

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

def test_boolean_field_with_none(self) -> None:
"""
BooleanField.to_python(None) raises ValidationError.
Ensure post_init doesn't crash when field value is None.
"""
with must_be_called(False) as test_func:
pre_save_changed.connect(test_func, sender=BooleanFieldModel)

obj = BooleanFieldModel()
# Should not raise ValidationError
post_init.send(instance=obj, sender=BooleanFieldModel)

# Only 'name' field should be tracked (is_active skipped due to ValidationError)
assert list(obj._fieldsignals_originals.values()) == [{"name": "test"}]

def test_boolean_field_transition_to_valid(self) -> None:
"""
When a BooleanField transitions from invalid (None) to valid (True/False),
the signal should fire with old_value=None.
"""
with must_be_called(True) as func:
pre_save_changed.connect(func, sender=BooleanFieldModel, fields=["is_active"])

obj = BooleanFieldModel()
post_init.send(instance=obj, sender=BooleanFieldModel)

obj.is_active = True
pre_save.send(instance=obj, sender=BooleanFieldModel)

assert func.kwargs["changed_fields"] == {"is_active": (None, True)}


class TestPostSave:
@pytest.fixture(autouse=True)
Expand Down