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