Plain is headed towards 1.0! Subscribe for development updates →

  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)