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.models.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 kwargs.setdefault("max_length", 100)
78 kwargs.setdefault("choices", self._get_timezone_choices())
79 super().__init__(**kwargs)
80
81 def _get_timezone_choices(self) -> list[tuple[str, str]]:
82 """Get timezone choices for form widgets."""
83 zones = [(tz, tz) for tz in _get_canonical_timezones()]
84 zones.sort(key=lambda x: x[1])
85 return [("", "---------")] + zones
86
87 def get_internal_type(self) -> str:
88 return "CharField"
89
90 def to_python(self, value: Any) -> zoneinfo.ZoneInfo | None:
91 """Convert input to ZoneInfo object."""
92 if value is None or value == "":
93 return None
94 if isinstance(value, zoneinfo.ZoneInfo):
95 return value
96 try:
97 return zoneinfo.ZoneInfo(value)
98 except zoneinfo.ZoneInfoNotFoundError:
99 raise exceptions.ValidationError(
100 f"'{value}' is not a valid timezone.",
101 code="invalid",
102 params={"value": value},
103 )
104
105 def from_db_value(
106 self, value: Any, expression: Any, connection: Any
107 ) -> zoneinfo.ZoneInfo | None:
108 """Convert database value to ZoneInfo object."""
109 if value is None or value == "":
110 return None
111 # Normalize legacy timezone names
112 value = self.LEGACY_TO_CANONICAL.get(value, value)
113 return zoneinfo.ZoneInfo(value)
114
115 def get_prep_value(self, value: Any) -> str | None:
116 """Convert ZoneInfo to string for database storage."""
117 if value is None:
118 return None
119 if isinstance(value, zoneinfo.ZoneInfo):
120 value = str(value)
121 # Normalize legacy timezone names before saving
122 return self.LEGACY_TO_CANONICAL.get(value, value)
123
124 def value_to_string(self, obj: Model) -> str:
125 """Serialize value for fixtures/migrations."""
126 value = self.value_from_object(obj)
127 prep_value = self.get_prep_value(value)
128 return prep_value if prep_value is not None else ""
129
130 def validate(self, value: Any, model_instance: Model) -> None:
131 """Validate value against choices using string comparison."""
132 # Convert ZoneInfo to string for choice validation since choices are strings
133 if isinstance(value, zoneinfo.ZoneInfo):
134 value = str(value)
135 return super().validate(value, model_instance)