Skip to content

Commit

Permalink
Convert TimeZoneField string value to timezone object on assignment
Browse files Browse the repository at this point in the history
Add a descriptor_class that deserializes a string
TimeZoneField value to a timezone object (pytz timezone
or zoneinfo depending on settings) when it is assigned.

As a side effect, invalid (non-blank) timezone names are
detected immediately (rather than at save/full_clean time),
and will immediately raise a ValidationError.

Closes #57
  • Loading branch information
medmunds committed Dec 7, 2023
1 parent ec2dab7 commit 028b24c
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 4 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <DstTzInfo 'America/Vancouver' LMT-1 day, 15:48:00 STD>
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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions tests/test_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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()


Expand All @@ -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()
3 changes: 3 additions & 0 deletions timezone_field/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -35,6 +36,8 @@ class TimeZoneField(models.Field):
stored as [<timezone object>, <str>].
"""

descriptor_class = AutoDeserializedAttribute

description = "A timezone object"

# NOTE: these defaults are excluded from migrations. If these are changed,
Expand Down
19 changes: 19 additions & 0 deletions timezone_field/utils.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 028b24c

Please sign in to comment.