Fixed #33279 -- Fixed handling time zones with "-" sign in names.

Thanks yakimka for the report.

Regression in fde9b7d35e.
This commit is contained in:
Can Sarigol 2021-11-11 09:57:50 +01:00 committed by Mariusz Felisiak
parent 78163d1ac4
commit 661316b066
7 changed files with 72 additions and 24 deletions

View File

@ -2,6 +2,7 @@ import uuid
from django.conf import settings from django.conf import settings
from django.db.backends.base.operations import BaseDatabaseOperations from django.db.backends.base.operations import BaseDatabaseOperations
from django.db.backends.utils import split_tzname_delta
from django.db.models import Exists, ExpressionWrapper, Lookup from django.db.models import Exists, ExpressionWrapper, Lookup
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import force_str from django.utils.encoding import force_str
@ -77,11 +78,8 @@ class DatabaseOperations(BaseDatabaseOperations):
return "DATE(%s)" % (field_name) return "DATE(%s)" % (field_name)
def _prepare_tzname_delta(self, tzname): def _prepare_tzname_delta(self, tzname):
if '+' in tzname: tzname, sign, offset = split_tzname_delta(tzname)
return tzname[tzname.find('+'):] return f'{sign}{offset}' if offset else tzname
elif '-' in tzname:
return tzname[tzname.find('-'):]
return tzname
def _convert_field_to_tz(self, field_name, tzname): def _convert_field_to_tz(self, field_name, tzname):
if tzname and settings.USE_TZ and self.connection.timezone_name != tzname: if tzname and settings.USE_TZ and self.connection.timezone_name != tzname:

View File

@ -5,7 +5,9 @@ from functools import lru_cache
from django.conf import settings from django.conf import settings
from django.db import DatabaseError, NotSupportedError from django.db import DatabaseError, NotSupportedError
from django.db.backends.base.operations import BaseDatabaseOperations from django.db.backends.base.operations import BaseDatabaseOperations
from django.db.backends.utils import strip_quotes, truncate_name from django.db.backends.utils import (
split_tzname_delta, strip_quotes, truncate_name,
)
from django.db.models import AutoField, Exists, ExpressionWrapper, Lookup from django.db.models import AutoField, Exists, ExpressionWrapper, Lookup
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.db.models.sql.where import WhereNode from django.db.models.sql.where import WhereNode
@ -108,11 +110,8 @@ END;
_tzname_re = _lazy_re_compile(r'^[\w/:+-]+$') _tzname_re = _lazy_re_compile(r'^[\w/:+-]+$')
def _prepare_tzname_delta(self, tzname): def _prepare_tzname_delta(self, tzname):
if '+' in tzname: tzname, sign, offset = split_tzname_delta(tzname)
return tzname[tzname.find('+'):] return f'{sign}{offset}' if offset else tzname
elif '-' in tzname:
return tzname[tzname.find('-'):]
return tzname
def _convert_field_to_tz(self, field_name, tzname): def _convert_field_to_tz(self, field_name, tzname):
if not (settings.USE_TZ and tzname): if not (settings.USE_TZ and tzname):

View File

@ -2,6 +2,7 @@ from psycopg2.extras import Inet
from django.conf import settings from django.conf import settings
from django.db.backends.base.operations import BaseDatabaseOperations from django.db.backends.base.operations import BaseDatabaseOperations
from django.db.backends.utils import split_tzname_delta
class DatabaseOperations(BaseDatabaseOperations): class DatabaseOperations(BaseDatabaseOperations):
@ -44,10 +45,10 @@ class DatabaseOperations(BaseDatabaseOperations):
return "DATE_TRUNC('%s', %s)" % (lookup_type, field_name) return "DATE_TRUNC('%s', %s)" % (lookup_type, field_name)
def _prepare_tzname_delta(self, tzname): def _prepare_tzname_delta(self, tzname):
if '+' in tzname: tzname, sign, offset = split_tzname_delta(tzname)
return tzname.replace('+', '-') if offset:
elif '-' in tzname: sign = '-' if sign == '+' else '+'
return tzname.replace('-', '+') return f'{tzname}{sign}{offset}'
return tzname return tzname
def _convert_field_to_tz(self, field_name, tzname): def _convert_field_to_tz(self, field_name, tzname):

View File

@ -434,14 +434,11 @@ def _sqlite_datetime_parse(dt, tzname=None, conn_tzname=None):
if conn_tzname: if conn_tzname:
dt = dt.replace(tzinfo=timezone_constructor(conn_tzname)) dt = dt.replace(tzinfo=timezone_constructor(conn_tzname))
if tzname is not None and tzname != conn_tzname: if tzname is not None and tzname != conn_tzname:
sign_index = tzname.find('+') + tzname.find('-') + 1 tzname, sign, offset = backend_utils.split_tzname_delta(tzname)
if sign_index > -1: if offset:
sign = tzname[sign_index] hours, minutes = offset.split(':')
tzname, offset = tzname.split(sign) offset_delta = datetime.timedelta(hours=int(hours), minutes=int(minutes))
if offset: dt += offset_delta if sign == '+' else -offset_delta
hours, minutes = offset.split(':')
offset_delta = datetime.timedelta(hours=int(hours), minutes=int(minutes))
dt += offset_delta if sign == '+' else -offset_delta
dt = timezone.localtime(dt, timezone_constructor(tzname)) dt = timezone.localtime(dt, timezone_constructor(tzname))
return dt return dt

View File

@ -7,6 +7,7 @@ from contextlib import contextmanager
from django.db import NotSupportedError from django.db import NotSupportedError
from django.utils.crypto import md5 from django.utils.crypto import md5
from django.utils.dateparse import parse_time
logger = logging.getLogger('django.db.backends') logger = logging.getLogger('django.db.backends')
@ -130,6 +131,18 @@ class CursorDebugWrapper(CursorWrapper):
) )
def split_tzname_delta(tzname):
"""
Split a time zone name into a 3-tuple of (name, sign, offset).
"""
for sign in ['+', '-']:
if sign in tzname:
name, offset = tzname.rsplit(sign, 1)
if offset and parse_time(offset):
return name, sign, offset
return tzname, None, None
############################################### ###############################################
# Converters from database (string) to Python # # Converters from database (string) to Python #
############################################### ###############################################

View File

@ -3,7 +3,7 @@ from decimal import Decimal, Rounded
from django.db import NotSupportedError, connection from django.db import NotSupportedError, connection
from django.db.backends.utils import ( from django.db.backends.utils import (
format_number, split_identifier, truncate_name, format_number, split_identifier, split_tzname_delta, truncate_name,
) )
from django.test import ( from django.test import (
SimpleTestCase, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature, SimpleTestCase, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature,
@ -57,6 +57,23 @@ class TestUtils(SimpleTestCase):
with self.assertRaises(Rounded): with self.assertRaises(Rounded):
equal('1234567890.1234', 5, None, '1234600000') equal('1234567890.1234', 5, None, '1234600000')
def test_split_tzname_delta(self):
tests = [
('Asia/Ust+Nera', ('Asia/Ust+Nera', None, None)),
('Asia/Ust-Nera', ('Asia/Ust-Nera', None, None)),
('Asia/Ust+Nera-02:00', ('Asia/Ust+Nera', '-', '02:00')),
('Asia/Ust-Nera+05:00', ('Asia/Ust-Nera', '+', '05:00')),
('America/Coral_Harbour-01:00', ('America/Coral_Harbour', '-', '01:00')),
('America/Coral_Harbour+02:30', ('America/Coral_Harbour', '+', '02:30')),
('UTC+15:00', ('UTC', '+', '15:00')),
('UTC-04:43', ('UTC', '-', '04:43')),
('UTC', ('UTC', None, None)),
('UTC+1', ('UTC+1', None, None)),
]
for tzname, expected in tests:
with self.subTest(tzname=tzname):
self.assertEqual(split_tzname_delta(tzname), expected)
class CursorWrapperTests(TransactionTestCase): class CursorWrapperTests(TransactionTestCase):
available_apps = [] available_apps = []

View File

@ -1210,6 +1210,29 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
self.assertEqual(melb_model.hour, 9) self.assertEqual(melb_model.hour, 9)
self.assertEqual(melb_model.hour_melb, 9) self.assertEqual(melb_model.hour_melb, 9)
def test_extract_func_with_timezone_minus_no_offset(self):
start_datetime = datetime(2015, 6, 15, 23, 30, 1, 321)
end_datetime = datetime(2015, 6, 16, 13, 11, 27, 123)
start_datetime = timezone.make_aware(start_datetime)
end_datetime = timezone.make_aware(end_datetime)
self.create_model(start_datetime, end_datetime)
for ust_nera in self.get_timezones('Asia/Ust-Nera'):
with self.subTest(repr(ust_nera)):
qs = DTModel.objects.annotate(
hour=ExtractHour('start_datetime'),
hour_tz=ExtractHour('start_datetime', tzinfo=ust_nera),
).order_by('start_datetime')
utc_model = qs.get()
self.assertEqual(utc_model.hour, 23)
self.assertEqual(utc_model.hour_tz, 9)
with timezone.override(ust_nera):
ust_nera_model = qs.get()
self.assertEqual(ust_nera_model.hour, 9)
self.assertEqual(ust_nera_model.hour_tz, 9)
def test_extract_func_explicit_timezone_priority(self): def test_extract_func_explicit_timezone_priority(self):
start_datetime = datetime(2015, 6, 15, 23, 30, 1, 321) start_datetime = datetime(2015, 6, 15, 23, 30, 1, 321)
end_datetime = datetime(2015, 6, 16, 13, 11, 27, 123) end_datetime = datetime(2015, 6, 16, 13, 11, 27, 123)