From 033f11be06ea507d714df450b0e770a7d70f3535 Mon Sep 17 00:00:00 2001 From: wesley Date: Tue, 8 Oct 2024 20:07:04 -0400 Subject: [PATCH] ticket #34699. Added a warning about using Trunc functions in a filter when the timezone is not UTC to database-functions. Added tests to confirm that the documentation is correct. --- docs/ref/models/database-functions.txt | 35 ++++++++++++++++ .../datetime/test_extract_trunc.py | 40 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index eb08e160f7..75c5030989 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -658,6 +658,41 @@ Usage example: 2015-06-15 14:30:50.000321 2015-06-15 14:40:02.000123 +.. warning:: + + Trunc functions, at the database level, return a timezone naive value which + is converted to a timezone aware value by the ORM. When you use a Trunc + function in a filter you will need to remember that it is a timezone naive + value. This can lead to unexpected results if you are using timezones other + than UTC. Django will store date/time values in the database in the UTC + timezone. The following example demonstrates what happens when using the + timezone "Europe/Berlin" and how to adjust for this: + +Filter example using the "Europe/Berlin" timezone.: + +.. code-block:: pycon + + >>> from django.utils import timezone + >>> from datetime import datetime + >>> from django.db.models.functions import TruncSecond + >>> import zoneinfo + >>> start = datetime(2015, 6, 15, 14, 30, 50, 321) + >>> start = timezone.make_aware(start) + >>> exp = Experiment.objects.create(start_datetime=start) + >>> find_this_exp = Experiment.objects.annotate( + ... trunc_start=TruncSecond("start_datetime") + ... ).filter(trunc_start__lte=start) + >>> find_this_exp.count() # We expect to find one result but 0 are found + ... + 0 + >>> start_adjusted = timezone.localtime(start).replace(tzinfo=zoneinfo.ZoneInfo(key='UTC')) + >>> find_this_exp_adjusted = Experiment.objects.annotate( + ... trunc_start=TruncSecond("start_datetime") + ... ).filter(trunc_start__lte=start_adjusted) + >>> find_this_exp.count() + ... + 1 + ``DateField`` truncation ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index 3f13ca7989..7d209a1caf 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/tests/db_functions/datetime/test_extract_trunc.py @@ -1935,3 +1935,43 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): DTModel.objects.annotate( hour_melb=Trunc("start_time", "hour", tzinfo=melb), ).get() + + def test_trunc_in_filter(self): + """ + ticket #34699. When TruncSecond is used in a filter it can behave unexpectedly + because the function at the database level returns a timezone naive value. The + documentation at docs/ref/models/database-functions.txt describes the problem + and provides a work-around in specific cases. These tests confirm the issue + exists and confirm that the work-around performs as described. If these tests + fail it could be because functionality has changed in which case the + documentation should be updated and the release notes should include information + about a potentially breaking change. + """ + # UTC: No adjustment required to filtering for TruncSecond + now = timezone.now() + later = now + timedelta(hours=2) + non_utc_model = self.create_model(now, later) + models_qs = DTModel.objects.annotate( + start_trunc=TruncSecond('start_datetime')).filter(id=non_utc_model.id, + start_trunc__lte=now) + self.assertEqual(models_qs.count(), 1) + test_timezones = [ + zoneinfo.ZoneInfo("Europe/Berlin"), + zoneinfo.ZoneInfo("Australia/Melbourne") + ] + for test_tz in test_timezones: + with timezone.override(test_tz): + now = timezone.now() + later = now + timedelta(hours=2) + non_utc_model = self.create_model(now, later) + models_qs = DTModel.objects.annotate( + start_trunc=TruncSecond('start_datetime')).filter(id=non_utc_model.id, + start_trunc__lte=now) + self.assertNotEqual(models_qs.count(), 1) + adjusted_now = timezone.localtime(now).replace(tzinfo=zoneinfo.ZoneInfo(key='UTC')) + models_qs = DTModel.objects.annotate( + start_trunc=TruncSecond('start_datetime')).filter(id=non_utc_model.id, + start_trunc__lte=adjusted_now) + self.assertEqual(models_qs.count(), 1) + +