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)