diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index 89730cee29..3e1faad41b 100644 --- a/django/db/backends/mysql/operations.py +++ b/django/db/backends/mysql/operations.py @@ -2,6 +2,7 @@ import uuid from django.conf import settings from django.db.backends.base.operations import BaseDatabaseOperations +from django.db.backends.utils import split_tzname_delta from django.utils import timezone from django.utils.encoding import force_str @@ -76,11 +77,8 @@ class DatabaseOperations(BaseDatabaseOperations): return "DATE(%s)" % (field_name) def _prepare_tzname_delta(self, tzname): - if '+' in tzname: - return tzname[tzname.find('+'):] - elif '-' in tzname: - return tzname[tzname.find('-'):] - return tzname + tzname, sign, offset = split_tzname_delta(tzname) + return f'{sign}{offset}' if offset else tzname def _convert_field_to_tz(self, field_name, tzname): if tzname and settings.USE_TZ and self.connection.timezone_name != tzname: diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 4cfc7da070..f497390bea 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -5,7 +5,9 @@ from functools import lru_cache from django.conf import settings from django.db import DatabaseError, NotSupportedError 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.expressions import RawSQL from django.db.models.sql.where import WhereNode @@ -108,11 +110,8 @@ END; _tzname_re = _lazy_re_compile(r'^[\w/:+-]+$') def _prepare_tzname_delta(self, tzname): - if '+' in tzname: - return tzname[tzname.find('+'):] - elif '-' in tzname: - return tzname[tzname.find('-'):] - return tzname + tzname, sign, offset = split_tzname_delta(tzname) + return f'{sign}{offset}' if offset else tzname def _convert_field_to_tz(self, field_name, tzname): if not (settings.USE_TZ and tzname): diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py index 8d19872bea..399c1b24e7 100644 --- a/django/db/backends/postgresql/operations.py +++ b/django/db/backends/postgresql/operations.py @@ -2,6 +2,7 @@ from psycopg2.extras import Inet from django.conf import settings from django.db.backends.base.operations import BaseDatabaseOperations +from django.db.backends.utils import split_tzname_delta class DatabaseOperations(BaseDatabaseOperations): @@ -44,10 +45,10 @@ class DatabaseOperations(BaseDatabaseOperations): return "DATE_TRUNC('%s', %s)" % (lookup_type, field_name) def _prepare_tzname_delta(self, tzname): - if '+' in tzname: - return tzname.replace('+', '-') - elif '-' in tzname: - return tzname.replace('-', '+') + tzname, sign, offset = split_tzname_delta(tzname) + if offset: + sign = '-' if sign == '+' else '+' + return f'{tzname}{sign}{offset}' return tzname def _convert_field_to_tz(self, field_name, tzname): diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index ddf5f40c8e..76ce12ac6d 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -433,14 +433,11 @@ def _sqlite_datetime_parse(dt, tzname=None, conn_tzname=None): if conn_tzname: dt = dt.replace(tzinfo=timezone_constructor(conn_tzname)) if tzname is not None and tzname != conn_tzname: - sign_index = tzname.find('+') + tzname.find('-') + 1 - if sign_index > -1: - sign = tzname[sign_index] - tzname, offset = tzname.split(sign) - if offset: - hours, minutes = offset.split(':') - offset_delta = datetime.timedelta(hours=int(hours), minutes=int(minutes)) - dt += offset_delta if sign == '+' else -offset_delta + tzname, sign, offset = backend_utils.split_tzname_delta(tzname) + if offset: + 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)) return dt diff --git a/django/db/backends/utils.py b/django/db/backends/utils.py index c342cf79b5..ff7523742f 100644 --- a/django/db/backends/utils.py +++ b/django/db/backends/utils.py @@ -7,6 +7,7 @@ import time from contextlib import contextmanager from django.db import NotSupportedError +from django.utils.dateparse import parse_time 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 # ############################################### diff --git a/tests/backends/test_utils.py b/tests/backends/test_utils.py index 7974dee607..54819829fd 100644 --- a/tests/backends/test_utils.py +++ b/tests/backends/test_utils.py @@ -3,7 +3,7 @@ from decimal import Decimal, Rounded from django.db import NotSupportedError, connection 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 ( SimpleTestCase, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature, @@ -57,6 +57,23 @@ class TestUtils(SimpleTestCase): with self.assertRaises(Rounded): 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): available_apps = [] diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index a129448470..71d9b676ed 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/tests/db_functions/datetime/test_extract_trunc.py @@ -1210,6 +1210,29 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): self.assertEqual(melb_model.hour, 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): start_datetime = datetime(2015, 6, 15, 23, 30, 1, 321) end_datetime = datetime(2015, 6, 16, 13, 11, 27, 123)