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