1from __future__ import annotations
  2
  3import datetime
  4import warnings
  5from collections.abc import Callable, Sequence
  6from functools import cached_property
  7from typing import TYPE_CHECKING, Any
  8
  9from plain import exceptions
 10from plain.preflight import PreflightResult
 11from plain.utils import timezone
 12from plain.utils.dateparse import parse_date, parse_datetime, parse_time
 13
 14from .base import NOT_PROVIDED, ColumnField, DefaultableField
 15
 16if TYPE_CHECKING:
 17    from plain.postgres.base import Model
 18    from plain.postgres.connection import DatabaseConnection
 19    from plain.postgres.expressions import Func
 20
 21
 22_INVALID_DATE_MESSAGE = (
 23    '"%(value)s" value has the correct format (YYYY-MM-DD) but it is an invalid date.'
 24)
 25
 26
 27def _to_naive(value: datetime.datetime) -> datetime.datetime:
 28    if timezone.is_aware(value):
 29        value = timezone.make_naive(value, datetime.UTC)
 30    return value
 31
 32
 33def _get_naive_now() -> datetime.datetime:
 34    return _to_naive(timezone.now())
 35
 36
 37def _check_if_value_fixed(
 38    field: Any,
 39    value: datetime.date | datetime.datetime,
 40    now: datetime.datetime | None = None,
 41) -> list[PreflightResult]:
 42    """Warn if `value` looks like a fixed near-now timestamp โ€” users usually
 43    want an `update_now=True` DateTimeField, not a one-shot literal.
 44    """
 45    if now is None:
 46        now = _get_naive_now()
 47    offset = datetime.timedelta(seconds=10)
 48    lower = now - offset
 49    upper = now + offset
 50    if isinstance(value, datetime.datetime):
 51        value = _to_naive(value)
 52    else:
 53        assert isinstance(value, datetime.date)
 54        lower = lower.date()
 55        upper = upper.date()
 56    if lower <= value <= upper:
 57        return [
 58            PreflightResult(
 59                fix="Fixed default value provided. "
 60                "It seems you set a fixed date / time / datetime "
 61                "value as default for this field. This may not be "
 62                "what you want. If you want to have the current date "
 63                "as default, use `plain.utils.timezone.now`",
 64                obj=field,
 65                id="fields.datetime_naive_default_value",
 66                warning=True,
 67            )
 68        ]
 69    return []
 70
 71
 72class DateField(DefaultableField[datetime.date]):
 73    db_type_sql = "date"
 74    empty_strings_allowed = False
 75
 76    def __init__(
 77        self,
 78        *,
 79        required: bool = True,
 80        allow_null: bool = False,
 81        default: Any = NOT_PROVIDED,
 82        validators: Sequence[Callable[..., Any]] = (),
 83    ):
 84        super().__init__(
 85            required=required,
 86            allow_null=allow_null,
 87            default=default,
 88            validators=validators,
 89        )
 90
 91    def preflight(self, **kwargs: Any) -> list[PreflightResult]:
 92        return [
 93            *super().preflight(**kwargs),
 94            *self._check_fix_default_value(),
 95        ]
 96
 97    def _check_fix_default_value(self) -> list[PreflightResult]:
 98        if not self.has_default():
 99            return []
100
101        value = self.default
102        if isinstance(value, datetime.datetime):
103            value = _to_naive(value).date()
104        elif isinstance(value, datetime.date):
105            pass
106        else:
107            return []
108        return _check_if_value_fixed(self, value)
109
110    def to_python(self, value: Any) -> datetime.date | None:
111        if value is None:
112            return value
113        if isinstance(value, datetime.datetime):
114            if timezone.is_aware(value):
115                # Convert aware datetimes to the default time zone
116                # before casting them to dates (#17742).
117                default_timezone = timezone.get_default_timezone()
118                value = timezone.make_naive(value, default_timezone)
119            return value.date()
120        if isinstance(value, datetime.date):
121            return value
122
123        try:
124            parsed = parse_date(value)
125            if parsed is not None:
126                return parsed
127        except ValueError:
128            raise exceptions.ValidationError(
129                _INVALID_DATE_MESSAGE,
130                code="invalid_date",
131                params={"value": value},
132            )
133
134        raise exceptions.ValidationError(
135            '"%(value)s" value has an invalid date format. It must be in YYYY-MM-DD format.',
136            code="invalid",
137            params={"value": value},
138        )
139
140    def get_prep_value(self, value: Any) -> Any:
141        value = super().get_prep_value(value)
142        return self.to_python(value)
143
144    def get_db_prep_value(
145        self, value: Any, connection: DatabaseConnection, prepared: bool = False
146    ) -> Any:
147        if not prepared:
148            value = self.get_prep_value(value)
149        return value
150
151
152class DateTimeField(ColumnField[datetime.datetime]):
153    db_type_sql = "timestamp with time zone"
154    empty_strings_allowed = False
155
156    def __init__(
157        self,
158        *,
159        create_now: bool = False,
160        update_now: bool = False,
161        required: bool = True,
162        allow_null: bool = False,
163        validators: Sequence[Callable[..., Any]] = (),
164    ):
165        self.create_now = create_now
166        self.update_now = update_now
167        super().__init__(
168            required=required,
169            allow_null=allow_null,
170            validators=validators,
171        )
172
173    @cached_property
174    def _db_default_expression(self) -> Func | None:
175        if self.create_now:
176            from plain.postgres.functions.datetime import Now
177
178            return Now()
179        return None
180
181    def get_db_default_expression(self) -> Func | None:
182        return self._db_default_expression
183
184    @property
185    def auto_fills_on_save(self) -> bool:
186        return self.update_now
187
188    def preflight(self, **kwargs: Any) -> list[PreflightResult]:
189        return [
190            *super().preflight(**kwargs),
191            *self._check_update_now_backfill(),
192        ]
193
194    def _check_update_now_backfill(self) -> list[PreflightResult]:
195        # update_now=True never implies a DB DEFAULT on its own (it only fires
196        # via Python pre_save), so adding the column to an existing table has
197        # no way to backfill rows unless the user also declares either a DB
198        # DEFAULT via create_now=True or a nullable column.
199        if self.update_now and not self.create_now and not self.allow_null:
200            return [
201                PreflightResult(
202                    fix="DateTimeField(update_now=True) must be paired with "
203                    "either create_now=True (DB DEFAULT backfills existing "
204                    "rows) or allow_null=True (column is nullable).",
205                    obj=self,
206                    id="fields.datetime_update_now_requires_backfill",
207                )
208            ]
209        return []
210
211    def deconstruct(self) -> tuple[str | None, str, list[Any], dict[str, Any]]:
212        name, path, args, kwargs = super().deconstruct()
213        if self.create_now:
214            kwargs["create_now"] = True
215        if self.update_now:
216            kwargs["update_now"] = True
217        return name, path, args, kwargs
218
219    def to_python(self, value: Any) -> datetime.datetime | None:
220        if value is None:
221            return value
222        if isinstance(value, datetime.datetime):
223            return value
224        if isinstance(value, datetime.date):
225            value = datetime.datetime(value.year, value.month, value.day)
226
227            # For backwards compatibility, interpret naive datetimes in
228            # local time. This won't work during DST change, but we can't
229            # do much about it, so we let the exceptions percolate up the
230            # call stack.
231            warnings.warn(
232                f"DateTimeField {self.model.__name__}.{self.name} received a naive datetime "
233                f"({value}) while time zone support is active.",
234                RuntimeWarning,
235            )
236            default_timezone = timezone.get_default_timezone()
237            value = timezone.make_aware(value, default_timezone)
238
239            return value
240
241        try:
242            parsed = parse_datetime(value)
243            if parsed is not None:
244                return parsed
245        except ValueError:
246            raise exceptions.ValidationError(
247                '"%(value)s" value has the correct format (YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ]) but it is an invalid date/time.',
248                code="invalid_datetime",
249                params={"value": value},
250            )
251
252        try:
253            parsed = parse_date(value)
254            if parsed is not None:
255                return datetime.datetime(parsed.year, parsed.month, parsed.day)
256        except ValueError:
257            raise exceptions.ValidationError(
258                _INVALID_DATE_MESSAGE,
259                code="invalid_date",
260                params={"value": value},
261            )
262
263        raise exceptions.ValidationError(
264            '"%(value)s" value has an invalid format. It must be in YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format.',
265            code="invalid",
266            params={"value": value},
267        )
268
269    def pre_save(self, model_instance: Model, add: bool) -> datetime.datetime | None:
270        if self.update_now:
271            value = timezone.now()
272            setattr(model_instance, self.attname, value)
273            return value
274        return getattr(model_instance, self.attname)
275
276    def get_prep_value(self, value: Any) -> Any:
277        value = super().get_prep_value(value)
278        value = self.to_python(value)
279        if value is not None and timezone.is_naive(value):
280            # For backwards compatibility, interpret naive datetimes in local
281            # time. This won't work during DST change, but we can't do much
282            # about it, so we let the exceptions percolate up the call stack.
283            try:
284                name = f"{self.model.__name__}.{self.name}"
285            except AttributeError:
286                name = "(unbound)"
287            warnings.warn(
288                f"DateTimeField {name} received a naive datetime ({value})"
289                " while time zone support is active.",
290                RuntimeWarning,
291            )
292            default_timezone = timezone.get_default_timezone()
293            value = timezone.make_aware(value, default_timezone)
294        return value
295
296    def get_db_prep_value(
297        self, value: Any, connection: DatabaseConnection, prepared: bool = False
298    ) -> Any:
299        if not prepared:
300            value = self.get_prep_value(value)
301        return value
302
303
304class TimeField(DefaultableField[datetime.time]):
305    db_type_sql = "time without time zone"
306    empty_strings_allowed = False
307
308    def __init__(
309        self,
310        *,
311        required: bool = True,
312        allow_null: bool = False,
313        default: Any = NOT_PROVIDED,
314        validators: Sequence[Callable[..., Any]] = (),
315    ):
316        super().__init__(
317            required=required,
318            allow_null=allow_null,
319            default=default,
320            validators=validators,
321        )
322
323    def preflight(self, **kwargs: Any) -> list[PreflightResult]:
324        return [
325            *super().preflight(**kwargs),
326            *self._check_fix_default_value(),
327        ]
328
329    def _check_fix_default_value(self) -> list[PreflightResult]:
330        if not self.has_default():
331            return []
332
333        value = self.default
334        if isinstance(value, datetime.datetime):
335            now = None
336        elif isinstance(value, datetime.time):
337            now = _get_naive_now()
338            # This will not use the right date in the race condition where now
339            # is just before the date change and value is just past 0:00.
340            value = datetime.datetime.combine(now.date(), value)
341        else:
342            return []
343        return _check_if_value_fixed(self, value, now=now)
344
345    def to_python(self, value: Any) -> datetime.time | None:
346        if value is None:
347            return None
348        if isinstance(value, datetime.time):
349            return value
350        if isinstance(value, datetime.datetime):
351            # Not usually a good idea to pass in a datetime here (it loses
352            # information), but we'll be accommodating.
353            return value.time()
354
355        try:
356            parsed = parse_time(value)
357            if parsed is not None:
358                return parsed
359        except ValueError:
360            raise exceptions.ValidationError(
361                '"%(value)s" value has the correct format (HH:MM[:ss[.uuuuuu]]) but it is an invalid time.',
362                code="invalid_time",
363                params={"value": value},
364            )
365
366        raise exceptions.ValidationError(
367            '"%(value)s" value has an invalid format. It must be in HH:MM[:ss[.uuuuuu]] format.',
368            code="invalid",
369            params={"value": value},
370        )
371
372    def get_prep_value(self, value: Any) -> Any:
373        value = super().get_prep_value(value)
374        return self.to_python(value)
375
376    def get_db_prep_value(
377        self, value: Any, connection: DatabaseConnection, prepared: bool = False
378    ) -> Any:
379        if not prepared:
380            value = self.get_prep_value(value)
381        return value