v0.146.0
  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)