diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 177715ecfae..7a582aa4043 100644 --- a/django/db/models/functions/datetime.py +++ b/django/db/models/functions/datetime.py @@ -170,8 +170,9 @@ class TruncBase(TimezoneMixin, Transform): kind = None tzinfo = None - def __init__(self, expression, output_field=None, tzinfo=None, **extra): + def __init__(self, expression, output_field=None, tzinfo=None, is_dst=None, **extra): self.tzinfo = tzinfo + self.is_dst = is_dst super().__init__(expression, output_field=output_field, **extra) def as_sql(self, compiler, connection): @@ -222,7 +223,7 @@ class TruncBase(TimezoneMixin, Transform): pass elif value is not None: value = value.replace(tzinfo=None) - value = timezone.make_aware(value, self.tzinfo) + value = timezone.make_aware(value, self.tzinfo, is_dst=self.is_dst) elif not connection.features.has_zoneinfo_database: raise ValueError( 'Database returned an invalid datetime value. Are time ' @@ -240,9 +241,12 @@ class TruncBase(TimezoneMixin, Transform): class Trunc(TruncBase): - def __init__(self, expression, kind, output_field=None, tzinfo=None, **extra): + def __init__(self, expression, kind, output_field=None, tzinfo=None, is_dst=None, **extra): self.kind = kind - super().__init__(expression, output_field=output_field, tzinfo=tzinfo, **extra) + super().__init__( + expression, output_field=output_field, tzinfo=tzinfo, + is_dst=is_dst, **extra + ) class TruncYear(TruncBase): diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index c6203f92c4a..46b41251a0c 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -442,7 +442,7 @@ Usage example:: ``Trunc`` --------- -.. class:: Trunc(expression, kind, output_field=None, tzinfo=None, **extra) +.. class:: Trunc(expression, kind, output_field=None, tzinfo=None, is_dst=None, **extra) Truncates a date up to a significant component. @@ -460,6 +460,14 @@ value. If ``output_field`` is omitted, it will default to the ``output_field`` of ``expression``. A ``tzinfo`` subclass, usually provided by ``pytz``, can be passed to truncate a value in a specific timezone. +The ``is_dst`` parameter indicates whether or not ``pytz`` should interpret +nonexistent and ambiguous datetimes in daylight saving time. By default (when +``is_dst=None``), ``pytz`` raises an exception for such datetimes. + +.. versionadded:: 3.0 + + The ``is_dst`` parameter was added. + Given the datetime ``2015-06-15 14:30:50.000321+00:00``, the built-in ``kind``\s return: @@ -525,21 +533,21 @@ Usage example:: ``DateField`` truncation ~~~~~~~~~~~~~~~~~~~~~~~~ -.. class:: TruncYear(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncYear(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'year' -.. class:: TruncMonth(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncMonth(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'month' -.. class:: TruncWeek(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncWeek(expression, output_field=None, tzinfo=None, is_dst=None, **extra) Truncates to midnight on the Monday of the week. .. attribute:: kind = 'week' -.. class:: TruncQuarter(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncQuarter(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'quarter' @@ -603,19 +611,19 @@ truncate function. It's also registered as a transform on ``DateTimeField`` as truncate function. It's also registered as a transform on ``DateTimeField`` as ``__time``. -.. class:: TruncDay(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncDay(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'day' -.. class:: TruncHour(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncHour(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'hour' -.. class:: TruncMinute(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncMinute(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'minute' -.. class:: TruncSecond(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncSecond(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'second' @@ -653,15 +661,15 @@ Usage example:: ``TimeField`` truncation ~~~~~~~~~~~~~~~~~~~~~~~~ -.. class:: TruncHour(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncHour(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'hour' -.. class:: TruncMinute(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncMinute(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'minute' -.. class:: TruncSecond(expression, output_field=None, tzinfo=None, **extra) +.. class:: TruncSecond(expression, output_field=None, tzinfo=None, is_dst=None, **extra) .. attribute:: kind = 'second' diff --git a/docs/releases/3.0.txt b/docs/releases/3.0.txt index 7d09d943997..1d4df533bd9 100644 --- a/docs/releases/3.0.txt +++ b/docs/releases/3.0.txt @@ -164,6 +164,10 @@ Models * Added the :class:`~django.db.models.functions.MD5` database function. +* The new ``is_dst`` parameter of the + :class:`~django.db.models.functions.Trunc` database functions determines the + treatment of nonexistent and ambiguous datetimes. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index 065a06f4beb..2088d09d069 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/tests/db_functions/datetime/test_extract_trunc.py @@ -1044,6 +1044,30 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): self.assertEqual(model.melb_year.year, 2016) self.assertEqual(model.pacific_year.year, 2015) + def test_trunc_ambiguous_and_invalid_times(self): + sao = pytz.timezone('America/Sao_Paulo') + utc = pytz.timezone('UTC') + start_datetime = utc.localize(datetime(2016, 10, 16, 13)) + end_datetime = utc.localize(datetime(2016, 2, 21, 1)) + self.create_model(start_datetime, end_datetime) + with timezone.override(sao): + with self.assertRaisesMessage(pytz.NonExistentTimeError, '2016-10-16 00:00:00'): + model = DTModel.objects.annotate(truncated_start=TruncDay('start_datetime')).get() + with self.assertRaisesMessage(pytz.AmbiguousTimeError, '2016-02-20 23:00:00'): + model = DTModel.objects.annotate(truncated_end=TruncHour('end_datetime')).get() + model = DTModel.objects.annotate( + truncated_start=TruncDay('start_datetime', is_dst=False), + truncated_end=TruncHour('end_datetime', is_dst=False), + ).get() + self.assertEqual(model.truncated_start.dst(), timedelta(0)) + self.assertEqual(model.truncated_end.dst(), timedelta(0)) + model = DTModel.objects.annotate( + truncated_start=TruncDay('start_datetime', is_dst=True), + truncated_end=TruncHour('end_datetime', is_dst=True), + ).get() + self.assertEqual(model.truncated_start.dst(), timedelta(0, 3600)) + self.assertEqual(model.truncated_end.dst(), timedelta(0, 3600)) + def test_trunc_func_with_timezone(self): """ If the truncated datetime transitions to a different offset (daylight