Fix DateTimeTzField crashing on driver-native datetimes and unparseable values (#18264)

This commit is contained in:
euvre
2026-08-14 00:56:05 -07:00
committed by GitHub
parent e4df8ebc5c
commit 0d198fccec
2 changed files with 79 additions and 8 deletions

View File

@@ -1316,15 +1316,24 @@ class DateTimeTzField(CharField):
return value.replace(tzinfo=timezone.utc).isoformat()
return value
def python_value(self, value: str | None) -> datetime | None:
if value is not None:
def python_value(self, value: str | datetime | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
# The column is declared VARCHAR, but deployments upgraded from
# older schemas may hold it as native DATETIME, in which case the
# driver returns datetime objects instead of ISO strings.
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
try:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
import pytz
return dt.replace(tzinfo=pytz.UTC)
return dt
return value
except ValueError:
# Zero dates (0000-00-00) and other unparseable values must not
# raise here: peewee counts the row before converting it, so one
# bad value wedges every later iteration over the table with
# IndexError: list index out of range.
logging.warning("DateTimeTzField: unparseable value %r, falling back to None", value)
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
class SyncLogs(DataBaseModel):

View File

@@ -0,0 +1,62 @@
"""
Tests for DateTimeTzField conversion between database driver values and
Python datetimes.
The sync_logs poll_range columns are declared VARCHAR, but deployments
upgraded from older schemas can hold them in native DATETIME columns, so the
driver may hand back datetime objects (or zero-date strings) instead of ISO
strings. Conversion must never raise: one bad value previously wedged the
whole sync-task scheduler with "list index out of range".
"""
from datetime import UTC, datetime
from api.db.db_models import DateTimeTzField
class TestDateTimeTzFieldPythonValue:
def test_none(self):
assert DateTimeTzField().python_value(None) is None
def test_iso_string_with_timezone(self):
dt = DateTimeTzField().python_value("2026-08-14T10:00:00.123456+00:00")
assert dt == datetime(2026, 8, 14, 10, 0, 0, 123456, tzinfo=UTC)
def test_iso_string_without_timezone_assumes_utc(self):
dt = DateTimeTzField().python_value("2026-08-14 10:00:00")
assert dt == datetime(2026, 8, 14, 10, 0, tzinfo=UTC)
def test_native_datetime_returned_by_driver(self):
naive = datetime(2026, 8, 14, 10, 0, 0, 123000, tzinfo=UTC).replace(tzinfo=None)
dt = DateTimeTzField().python_value(naive)
assert dt == datetime(2026, 8, 14, 10, 0, 0, 123000, tzinfo=UTC)
def test_native_aware_datetime_kept(self):
aware = datetime(2026, 8, 14, 10, 0, tzinfo=UTC)
assert DateTimeTzField().python_value(aware) is aware
def test_zero_date_string_falls_back_to_none(self):
assert DateTimeTzField().python_value("0000-00-00 00:00:00") is None
def test_garbage_string_falls_back_to_none(self):
assert DateTimeTzField().python_value("not-a-date") is None
def test_empty_string_falls_back_to_none(self):
assert DateTimeTzField().python_value("") is None
class TestDateTimeTzFieldDbValue:
def test_none(self):
assert DateTimeTzField().db_value(None) is None
def test_naive_datetime_written_as_utc_iso(self):
naive = datetime(2026, 8, 14, 10, 0, tzinfo=UTC).replace(tzinfo=None)
assert DateTimeTzField().db_value(naive) == "2026-08-14T10:00:00+00:00"
def test_aware_datetime_written_as_iso(self):
assert DateTimeTzField().db_value(datetime(2026, 8, 14, 10, 0, tzinfo=UTC)) == "2026-08-14T10:00:00+00:00"
def test_db_value_round_trips_through_python_value(self):
field = DateTimeTzField()
stored = field.db_value(datetime(2026, 8, 14, 10, 0, 0, 123456, tzinfo=UTC))
assert field.python_value(stored) == datetime(2026, 8, 14, 10, 0, 0, 123456, tzinfo=UTC)