v0.163.0
  1from __future__ import annotations
  2
  3import zoneinfo
  4from collections.abc import Callable, Sequence
  5from functools import cache
  6from typing import TYPE_CHECKING, Any, ClassVar
  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[
 51    T: (zoneinfo.ZoneInfo, zoneinfo.ZoneInfo | None) = zoneinfo.ZoneInfo
 52](ChoicesField[T]):
 53    """
 54    A model field that stores timezone names as strings but provides ZoneInfo objects.
 55
 56    Similar to DateField which stores dates but provides datetime.date objects,
 57    this field stores timezone strings (e.g., "America/Chicago") but provides
 58    zoneinfo.ZoneInfo objects when accessed.
 59    """
 60
 61    db_type_sql = "character varying"
 62
 63    # Mapping of legacy timezone names to canonical IANA names
 64    # Based on IANA timezone database backward compatibility file
 65    LEGACY_TO_CANONICAL: ClassVar = {
 66        "US/Alaska": "America/Anchorage",
 67        "US/Aleutian": "America/Adak",
 68        "US/Arizona": "America/Phoenix",
 69        "US/Central": "America/Chicago",
 70        "US/East-Indiana": "America/Indiana/Indianapolis",
 71        "US/Eastern": "America/New_York",
 72        "US/Hawaii": "Pacific/Honolulu",
 73        "US/Indiana-Starke": "America/Indiana/Knox",
 74        "US/Michigan": "America/Detroit",
 75        "US/Mountain": "America/Denver",
 76        "US/Pacific": "America/Los_Angeles",
 77        "US/Samoa": "Pacific/Pago_Pago",
 78    }
 79
 80    # Legacy varchar(100) column — pending migration to text.
 81    max_length = 100
 82
 83    def __init__(
 84        self,
 85        *,
 86        required: bool = True,
 87        allow_null: bool = False,
 88        default: Any = NOT_PROVIDED,
 89        validators: Sequence[Callable[..., Any]] = (),
 90    ):
 91        # `choices` is intentionally not accepted: the canonical timezone list
 92        # is populated internally from the system tzdata.
 93        super().__init__(
 94            choices=self._get_timezone_choices(),
 95            required=required,
 96            allow_null=allow_null,
 97            default=default,
 98            validators=validators,
 99        )
100
101    def deconstruct(self) -> tuple[str, str, list[Any], dict[str, Any]]:
102        name, path, args, kwargs = super().deconstruct()
103        # Don't serialize choices - they're computed dynamically from system tzdata
104        kwargs.pop("choices", None)
105        return name, path, args, kwargs
106
107    def _get_timezone_choices(self) -> list[tuple[str, str]]:
108        """Get timezone choices for form widgets."""
109        zones = [(tz, tz) for tz in _get_canonical_timezones()]
110        zones.sort(key=lambda x: x[1])
111        return [("", "---------")] + zones
112
113    def db_type(self) -> str | None:
114        if self.max_length is None:
115            return "character varying"
116        return f"character varying({self.max_length})"
117
118    def _max_length_for_choices_check(self) -> int | None:
119        return self.max_length
120
121    def to_python(self, value: Any) -> zoneinfo.ZoneInfo | None:
122        """Convert input to ZoneInfo object."""
123        if value is None or value == "":
124            return None
125        if isinstance(value, zoneinfo.ZoneInfo):
126            return value
127        try:
128            return zoneinfo.ZoneInfo(value)
129        except zoneinfo.ZoneInfoNotFoundError:
130            raise exceptions.ValidationError(
131                f"'{value}' is not a valid timezone.",
132                code="invalid",
133                params={"value": value},
134            )
135
136    def from_db_value(
137        self, value: Any, expression: Any, connection: Any
138    ) -> zoneinfo.ZoneInfo | None:
139        """Convert database value to ZoneInfo object."""
140        if value is None or value == "":
141            return None
142        # Normalize legacy timezone names
143        value = self.LEGACY_TO_CANONICAL.get(value, value)
144        return zoneinfo.ZoneInfo(value)
145
146    def get_prep_value(self, value: Any) -> str | None:
147        """Convert ZoneInfo to string for database storage."""
148        if value is None:
149            return None
150        if isinstance(value, zoneinfo.ZoneInfo):
151            value = str(value)
152        # Normalize legacy timezone names before saving
153        return self.LEGACY_TO_CANONICAL.get(value, value)
154
155    def validate(self, value: Any, model_instance: Model) -> None:
156        """Validate value against choices using string comparison."""
157        # Convert ZoneInfo to string for choice validation since choices are strings
158        if isinstance(value, zoneinfo.ZoneInfo):
159            value = str(value)
160        return super().validate(value, model_instance)