diff --git a/README.md b/README.md index 6c12432..5e93ee1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ my_model.full_clean() # validates against pytz.common_timezones by default my_model.save() # values stored in DB as strings my_model.tz3 # value returned as pytz timezone: my_model.tz4 # value returned as zoneinfo: zoneinfo.ZoneInfo(key='America/Vancouver') + +my_model.tz1 = "UTC" # assignment of a string, immediately converted to timezone object +my_model.tz1 # zoneinfo.ZoneInfo(key='UTC') or pytz.utc, depending on use_pytz default +my_model.tz2 = "Invalid/Not_A_Zone" # immediately raises ValidationError ``` ### Form Field @@ -134,6 +138,14 @@ poetry run pytest ## Changelog +#### `main` (unreleased) + +- Convert string value to timezone object immediately on creation/assignment. + Accessing a TimeZoneField will _always_ return a timezone or None (never a string). + (Potentially BREAKING: Unknown timezone names now raise `ValidationError` at time of assignment. + Previously, conversion was delayed until model `full_clean` or `save`.) + ([#57](https://github.com/mfogel/django-timezone-field/issues/57)) + #### 6.1.0 (2023-11-25) - Add support for django 5.0 diff --git a/tests/test_field.py b/tests/test_field.py index d091a69..d357d8a 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -61,11 +61,30 @@ def test_string_value_lookup(Model, pst, filter_tz): assert qs.count() == 1 +@pytest.mark.parametrize( + "input_tz, output_tz", + [ + [lazy_fixture("pst"), lazy_fixture("pst_tz")], + [lazy_fixture("pst_tz"), lazy_fixture("pst_tz")], + ["", None], + [None, None], + ], +) +def test_string_value_assignment_without_save(Model, utc, input_tz, output_tz): + m = Model(tz=utc, tz_opt=utc) + m.tz_opt = input_tz + assert m.tz_opt == output_tz + + @pytest.mark.parametrize("tz", [None, "", "not-a-tz", 4, object()]) def test_invalid_input(Model, tz): - m = Model(tz=tz) with pytest.raises(ValidationError): - m.full_clean() + # Most invalid values are detected at creation/assignment. + # Invalid blank values aren't detected until clean/save. + m = Model(tz=tz) + if tz is None or tz == "": + assert m.tz is None + m.full_clean() def test_three_positional_args_does_not_throw(): @@ -92,8 +111,8 @@ def test_with_limited_choices_valid_choice(ModelChoice, pst, pst_tz): @pytest.mark.parametrize("kwargs", [{"tz_superset": "not a tz"}, {"tz_subset": "Europe/Brussels"}]) def test_with_limited_choices_invalid_choice(ModelChoice, kwargs): - m = ModelChoice(**kwargs) with pytest.raises(ValidationError): + m = ModelChoice(**kwargs) m.full_clean() @@ -107,6 +126,6 @@ def test_with_limited_choices_old_format_valid_choice(ModelOldChoiceFormat, pst, @pytest.mark.parametrize("kwargs", [{"tz_superset": "not a tz"}, {"tz_subset": "Europe/Brussels"}]) def test_with_limited_choices_old_format_invalid_choice(ModelOldChoiceFormat, kwargs): - m = ModelOldChoiceFormat(**kwargs) with pytest.raises(ValidationError): + m = ModelOldChoiceFormat(**kwargs) m.full_clean() diff --git a/timezone_field/fields.py b/timezone_field/fields.py index 88e2042..e770d16 100644 --- a/timezone_field/fields.py +++ b/timezone_field/fields.py @@ -4,6 +4,7 @@ from timezone_field.backends import TimeZoneNotFoundError, get_tz_backend from timezone_field.choices import standard, with_gmt_offset +from timezone_field.utils import AutoDeserializedAttribute class TimeZoneField(models.Field): @@ -35,6 +36,8 @@ class TimeZoneField(models.Field): stored as [, ]. """ + descriptor_class = AutoDeserializedAttribute + description = "A timezone object" # NOTE: these defaults are excluded from migrations. If these are changed, diff --git a/timezone_field/utils.py b/timezone_field/utils.py new file mode 100644 index 0000000..467e72a --- /dev/null +++ b/timezone_field/utils.py @@ -0,0 +1,19 @@ +from django.db.models.query_utils import DeferredAttribute + + +class AutoDeserializedAttribute(DeferredAttribute): + """ + Use as the descriptor_class for a Django custom field. + Allows setting the field to a serialized (typically string) value, + and immediately reflecting that as the deserialized `to_python` value. + + (This requires that the field's `to_python` returns the same thing + whether called with a serialized or deserialized value.) + """ + + # (Adapted from django.db.models.fields.subclassing.Creator, + # which was included in Django 1.8 and earlier.) + + def __set__(self, instance, value): + value = self.field.to_python(value) + instance.__dict__[self.field.attname] = value