Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert TimeZoneField string value to timezone object on assignment #102

Merged
merged 1 commit into from
Dec 9, 2023
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
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