1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
|
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.encoding import force_str
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):
"""
Provides database store for pytz timezone objects.
Valid inputs:
* use_pytz=True:
* any instance of pytz.tzinfo.DstTzInfo or pytz.tzinfo.StaticTzInfo
* the pytz.UTC singleton
* any string that validates against pytz.common_timezones. pytz will
be used to build a timezone object from the string.
* use_pytz=False:
* any instance of zoneinfo.ZoneInfo
* any string that validates against zoneinfo.available_timezones().
* None and the empty string both represent 'no timezone'
Valid outputs:
* None
* use_pytz=True: instances of pytz.tzinfo.DstTzInfo,
pytz.tzinfo.StaticTzInfo and the pytz.UTC singleton
* use_pytz=False: instances of zoneinfo.ZoneInfo
Blank values are stored in the DB as the empty string. Timezones are stored
in their string representation.
The `choices` kwarg can be specified as a list of either
[<timezone object>, <str>] or [<str>, <str>]. Internally in memory, it is
stored as [<timezone object>, <str>].
"""
descriptor_class = AutoDeserializedAttribute
description = "A timezone object"
# NOTE: these defaults are excluded from migrations. If these are changed,
# existing migration files will need to be accomodated.
default_max_length = 63
def __init__(self, *args, **kwargs):
# allow some use of positional args up until the args we customize
# https://github.com/mfogel/django-timezone-field/issues/42
# https://github.com/django/django/blob/1.11.11/django/db/models/fields/__init__.py#L145
if len(args) > 3:
raise ValueError("Cannot specify max_length by positional arg")
kwargs.setdefault("max_length", self.default_max_length)
self.use_pytz = kwargs.pop("use_pytz", None)
self.tz_backend = get_tz_backend(self.use_pytz)
self.default_tzs = [self.tz_backend.to_tzobj(v) for v in self.tz_backend.base_tzstrs]
if "choices" in kwargs:
values, displays = zip(*kwargs["choices"])
# Choices can be specified in two forms: either
# [<timezone object>, <str>] or [<str>, <str>]
#
# The [<timezone object>, <str>] format is the one we actually
# store the choices in memory because of
# https://github.com/mfogel/django-timezone-field/issues/24
#
# The [<str>, <str>] format is supported because since django
# can't deconstruct pytz.timezone objects, migration files must
# use an alternate format. Representing the timezones as strings
# is the obvious choice.
if not self.tz_backend.is_tzobj(values[0]):
# using force_str b/c of https://github.com/mfogel/django-timezone-field/issues/38
values = [self.tz_backend.to_tzobj(force_str(v)) for v in values]
else:
values = self.default_tzs
displays = None
self.choices_display = kwargs.pop("choices_display", None)
if self.choices_display == "WITH_GMT_OFFSET":
choices = with_gmt_offset(values, use_pytz=self.use_pytz)
elif self.choices_display == "STANDARD":
choices = standard(values)
elif self.choices_display is None:
choices = zip(values, displays) if displays else standard(values)
else:
raise ValueError(f"Unrecognized value for kwarg 'choices_display' of '{self.choices_display}'")
kwargs["choices"] = choices
super().__init__(*args, **kwargs)
def validate(self, value, model_instance):
if not self.tz_backend.is_tzobj(value):
raise ValidationError(f"'{value}' is not a pytz timezone object")
super().validate(value, model_instance)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if kwargs.get("max_length") == self.default_max_length:
del kwargs["max_length"]
if self.use_pytz is not None:
kwargs["use_pytz"] = self.use_pytz
if self.choices_display is not None:
kwargs["choices_display"] = self.choices_display
# don't assume super().deconstruct() will pass us back our kwargs["choices"]
# https://github.com/mfogel/django-timezone-field/issues/96
if "choices" in kwargs:
if self.choices_display is None:
if kwargs["choices"] == standard(self.default_tzs):
kwargs.pop("choices")
else:
values, _ = zip(*kwargs["choices"])
if sorted(values, key=str) == sorted(self.default_tzs, key=str):
kwargs.pop("choices")
else:
kwargs["choices"] = [(value, "") for value in values]
# django can't decontruct pytz objects, so transform choices
# to [<str>, <str>] format for writing out to the migration
if "choices" in kwargs:
kwargs["choices"] = [(str(tz), n) for tz, n in kwargs["choices"]]
return name, path, args, kwargs
def get_internal_type(self):
return "CharField"
def get_default(self):
# allow defaults to be still specified as strings. Allows for easy
# serialization into migration files
value = super().get_default()
return self._get_python_and_db_repr(value)[0]
def from_db_value(self, value, *_args):
"Convert to pytz timezone object"
return self._get_python_and_db_repr(value)[0]
def to_python(self, value):
"Convert to pytz timezone object"
return self._get_python_and_db_repr(value)[0]
def get_prep_value(self, value):
"Convert to string describing a valid pytz timezone object"
return self._get_python_and_db_repr(value)[1]
def _get_python_and_db_repr(self, value):
"Returns a tuple of (python representation, db representation)"
if value is None or value == "":
return (None, "")
if self.tz_backend.is_tzobj(value):
return (value, str(value))
try:
return (self.tz_backend.to_tzobj(force_str(value)), force_str(value))
except TimeZoneNotFoundError as err:
raise ValidationError(f"Invalid timezone '{value}'") from err
|