1from __future__ import annotations
2
3import zoneinfo
4from collections.abc import Callable, Sequence
5from functools import cache
6from typing import TYPE_CHECKING, Any, ClassVar
7
8from plain import exceptions
9
10from . import ChoicesField
11from .base import NOT_PROVIDED
12
13if TYPE_CHECKING:
14 from plain.postgres.base import Model
15
16
17@cache
18def _get_canonical_timezones() -> frozenset[str]:
19 """
20 Get canonical IANA timezone names, excluding deprecated legacy aliases.
21
22 Filters out legacy timezone names like US/Central, Canada/Eastern, etc.
23 that are backward compatibility aliases. These legacy names can cause
24 issues with databases like PostgreSQL that only recognize canonical names.
25 """
26 all_zones = zoneinfo.available_timezones()
27
28 # Known legacy prefixes (deprecated in favor of Area/Location format)
29 legacy_prefixes = ("US/", "Canada/", "Brazil/", "Chile/", "Mexico/")
30
31 # Obsolete timezone abbreviations
32 obsolete_zones = {
33 "EST",
34 "MST",
35 "HST",
36 "EST5EDT",
37 "CST6CDT",
38 "MST7MDT",
39 "PST8PDT",
40 }
41
42 # Filter to only canonical timezone names
43 return frozenset(
44 tz
45 for tz in all_zones
46 if not tz.startswith(legacy_prefixes) and tz not in obsolete_zones
47 )
48
49
50class TimeZoneField[
51 T: (zoneinfo.ZoneInfo, zoneinfo.ZoneInfo | None) = zoneinfo.ZoneInfo
52](ChoicesField[T]):
53 """
54 A model field that stores timezone names as strings but provides ZoneInfo objects.
55
56 Similar to DateField which stores dates but provides datetime.date objects,
57 this field stores timezone strings (e.g., "America/Chicago") but provides
58 zoneinfo.ZoneInfo objects when accessed.
59 """
60
61 db_type_sql = "character varying"
62
63 # Mapping of legacy timezone names to canonical IANA names
64 # Based on IANA timezone database backward compatibility file
65 LEGACY_TO_CANONICAL: ClassVar = {
66 "US/Alaska": "America/Anchorage",
67 "US/Aleutian": "America/Adak",
68 "US/Arizona": "America/Phoenix",
69 "US/Central": "America/Chicago",
70 "US/East-Indiana": "America/Indiana/Indianapolis",
71 "US/Eastern": "America/New_York",
72 "US/Hawaii": "Pacific/Honolulu",
73 "US/Indiana-Starke": "America/Indiana/Knox",
74 "US/Michigan": "America/Detroit",
75 "US/Mountain": "America/Denver",
76 "US/Pacific": "America/Los_Angeles",
77 "US/Samoa": "Pacific/Pago_Pago",
78 }
79
80 # Legacy varchar(100) column — pending migration to text.
81 max_length = 100
82
83 def __init__(
84 self,
85 *,
86 required: bool = True,
87 allow_null: bool = False,
88 default: Any = NOT_PROVIDED,
89 validators: Sequence[Callable[..., Any]] = (),
90 ):
91 # `choices` is intentionally not accepted: the canonical timezone list
92 # is populated internally from the system tzdata.
93 super().__init__(
94 choices=self._get_timezone_choices(),
95 required=required,
96 allow_null=allow_null,
97 default=default,
98 validators=validators,
99 )
100
101 def deconstruct(self) -> tuple[str, str, list[Any], dict[str, Any]]:
102 name, path, args, kwargs = super().deconstruct()
103 # Don't serialize choices - they're computed dynamically from system tzdata
104 kwargs.pop("choices", None)
105 return name, path, args, kwargs
106
107 def _get_timezone_choices(self) -> list[tuple[str, str]]:
108 """Get timezone choices for form widgets."""
109 zones = [(tz, tz) for tz in _get_canonical_timezones()]
110 zones.sort(key=lambda x: x[1])
111 return [("", "---------")] + zones
112
113 def db_type(self) -> str | None:
114 if self.max_length is None:
115 return "character varying"
116 return f"character varying({self.max_length})"
117
118 def _max_length_for_choices_check(self) -> int | None:
119 return self.max_length
120
121 def to_python(self, value: Any) -> zoneinfo.ZoneInfo | None:
122 """Convert input to ZoneInfo object."""
123 if value is None or value == "":
124 return None
125 if isinstance(value, zoneinfo.ZoneInfo):
126 return value
127 try:
128 return zoneinfo.ZoneInfo(value)
129 except zoneinfo.ZoneInfoNotFoundError:
130 raise exceptions.ValidationError(
131 f"'{value}' is not a valid timezone.",
132 code="invalid",
133 params={"value": value},
134 )
135
136 def from_db_value(
137 self, value: Any, expression: Any, connection: Any
138 ) -> zoneinfo.ZoneInfo | None:
139 """Convert database value to ZoneInfo object."""
140 if value is None or value == "":
141 return None
142 # Normalize legacy timezone names
143 value = self.LEGACY_TO_CANONICAL.get(value, value)
144 return zoneinfo.ZoneInfo(value)
145
146 def get_prep_value(self, value: Any) -> str | None:
147 """Convert ZoneInfo to string for database storage."""
148 if value is None:
149 return None
150 if isinstance(value, zoneinfo.ZoneInfo):
151 value = str(value)
152 # Normalize legacy timezone names before saving
153 return self.LEGACY_TO_CANONICAL.get(value, value)
154
155 def validate(self, value: Any, model_instance: Model) -> None:
156 """Validate value against choices using string comparison."""
157 # Convert ZoneInfo to string for choice validation since choices are strings
158 if isinstance(value, zoneinfo.ZoneInfo):
159 value = str(value)
160 return super().validate(value, model_instance)