|
8 | 8 |
|
9 | 9 | import pytest |
10 | 10 | from django.apps import apps |
11 | | -from django.core.exceptions import AppRegistryNotReady |
| 11 | +from django.core.exceptions import AppRegistryNotReady, ValidationError |
12 | 12 | from django.db.models.fields.related import OneToOneRel |
13 | 13 | from django.db.models.signals import post_init, post_save, pre_save |
14 | 14 | from django.utils.dateparse import parse_datetime |
@@ -69,6 +69,13 @@ def to_python(self, value: Any) -> datetime.datetime | None: |
69 | 69 | return parse_datetime(value) |
70 | 70 |
|
71 | 71 |
|
| 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 | + |
72 | 79 | class FakeModel: |
73 | 80 | a_key = "a value" |
74 | 81 | another = "something else" |
@@ -104,6 +111,22 @@ def get_deferred_fields(self) -> set[str]: |
104 | 111 | return {"b"} |
105 | 112 |
|
106 | 113 |
|
| 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 | + |
107 | 130 | class MockOneToOneRel(OneToOneRel): |
108 | 131 | def __init__(self, name: str) -> None: |
109 | 132 | self.name = name |
@@ -169,6 +192,37 @@ def test_deferred_fields(self) -> None: |
169 | 192 |
|
170 | 193 | assert list(obj._fieldsignals_originals.values()) == [{"a": 1}] |
171 | 194 |
|
| 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 | + |
172 | 226 |
|
173 | 227 | class TestPostSave: |
174 | 228 | @pytest.fixture(autouse=True) |
|
0 commit comments