1from __future__ import annotations
2
3import zoneinfo
4from collections.abc import Callable, Sequence
5from functools import cache
6from typing import TYPE_CHECKING, Any
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(ChoicesField[zoneinfo.ZoneInfo]):
51 """
52 A model field that stores timezone names as strings but provides ZoneInfo objects.
53
54 Similar to DateField which stores dates but provides datetime.date objects,
55 this field stores timezone strings (e.g., "America/Chicago") but provides
56 zoneinfo.ZoneInfo objects when accessed.
57 """
58
59 db_type_sql = "character varying"
60
61 # Mapping of legacy timezone names to canonical IANA names
62 # Based on IANA timezone database backward compatibility file
63 LEGACY_TO_CANONICAL = {
64 "US/Alaska": "America/Anchorage",
65 "US/Aleutian": "America/Adak",
66 "US/Arizona": "America/Phoenix",
67 "US/Central": "America/Chicago",
68 "US/East-Indiana": "America/Indiana/Indianapolis",
69 "US/Eastern": "America/New_York",
70 "US/Hawaii": "Pacific/Honolulu",
71 "US/Indiana-Starke": "America/Indiana/Knox",
72 "US/Michigan": "America/Detroit",
73 "US/Mountain": "America/Denver",
74 "US/Pacific": "America/Los_Angeles",
75 "US/Samoa": "Pacific/Pago_Pago",
76 }
77
78 # Legacy varchar(100) column — pending migration to text.
79 max_length = 100
80
81 def __init__(
82 self,
83 *,
84 required: bool = True,
85 allow_null: bool = False,
86 default: Any = NOT_PROVIDED,
87 validators: Sequence[Callable[..., Any]] = (),
88 ):
89 # `choices` is intentionally not accepted: the canonical timezone list
90 # is populated internally from the system tzdata.
91 super().__init__(
92 choices=self._get_timezone_choices(),
93 required=required,
94 allow_null=allow_null,
95 default=default,
96 validators=validators,
97 )
98
99 def deconstruct(self) -> tuple[str | None, str, list[Any], dict[str, Any]]:
100 name, path, args, kwargs = super().deconstruct()
101 # Don't serialize choices - they're computed dynamically from system tzdata
102 kwargs.pop("choices", None)
103 return name, path, args, kwargs
104
105 def _get_timezone_choices(self) -> list[tuple[str, str]]:
106 """Get timezone choices for form widgets."""
107 zones = [(tz, tz) for tz in _get_canonical_timezones()]
108 zones.sort(key=lambda x: x[1])
109 return [("", "---------")] + zones
110
111 def db_type(self) -> str | None:
112 if self.max_length is None:
113 return "character varying"
114 return f"character varying({self.max_length})"
115
116 def _max_length_for_choices_check(self) -> int | None:
117 return self.max_length
118
119 def to_python(self, value: Any) -> zoneinfo.ZoneInfo | None:
120 """Convert input to ZoneInfo object."""
121 if value is None or value == "":
122 return None
123 if isinstance(value, zoneinfo.ZoneInfo):
124 return value
125 try:
126 return zoneinfo.ZoneInfo(value)
127 except zoneinfo.ZoneInfoNotFoundError:
128 raise exceptions.ValidationError(
129 f"'{value}' is not a valid timezone.",
130 code="invalid",
131 params={"value": value},
132 )
133
134 def from_db_value(
135 self, value: Any, expression: Any, connection: Any
136 ) -> zoneinfo.ZoneInfo | None:
137 """Convert database value to ZoneInfo object."""
138 if value is None or value == "":
139 return None
140 # Normalize legacy timezone names
141 value = self.LEGACY_TO_CANONICAL.get(value, value)
142 return zoneinfo.ZoneInfo(value)
143
144 def get_prep_value(self, value: Any) -> str | None:
145 """Convert ZoneInfo to string for database storage."""
146 if value is None:
147 return None
148 if isinstance(value, zoneinfo.ZoneInfo):
149 value = str(value)
150 # Normalize legacy timezone names before saving
151 return self.LEGACY_TO_CANONICAL.get(value, value)
152
153 def validate(self, value: Any, model_instance: Model) -> None:
154 """Validate value against choices using string comparison."""
155 # Convert ZoneInfo to string for choice validation since choices are strings
156 if isinstance(value, zoneinfo.ZoneInfo):
157 value = str(value)
158 return super().validate(value, model_instance)