File: fields.py

package info (click to toggle)
python-django-timezone-field 7.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 292 kB
  • sloc: python: 1,047; sh: 6; makefile: 3
file content (158 lines) | stat: -rw-r--r-- 6,740 bytes parent folder | download
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