Fixed #23820 -- Supported per-database time zone.
The primary use case is to interact with a third-party database (not primarily managed by Django) that doesn't support time zones and where datetimes are stored in local time when USE_TZ is True. Configuring a PostgreSQL database with the TIME_ZONE option while USE_TZ is False used to result in silent data corruption. Now this is an error.
This commit is contained in:
parent
54026f1e8d
commit
ed83881e64
|
@ -20,10 +20,9 @@ router = ConnectionRouter()
|
|||
# `connection`, `DatabaseError` and `IntegrityError` are convenient aliases
|
||||
# for backend bits.
|
||||
|
||||
# DatabaseWrapper.__init__() takes a dictionary, not a settings module, so
|
||||
# we manually create the dictionary from the settings, passing only the
|
||||
# settings that the database backends care about. Note that TIME_ZONE is used
|
||||
# by the PostgreSQL backends.
|
||||
# DatabaseWrapper.__init__() takes a dictionary, not a settings module, so we
|
||||
# manually create the dictionary from the settings, passing only the settings
|
||||
# that the database backends care about.
|
||||
# We load all these up for backwards compatibility, you should use
|
||||
# connections['default'] instead.
|
||||
class DefaultConnectionProxy(object):
|
||||
|
|
|
@ -4,14 +4,21 @@ from collections import deque
|
|||
from contextlib import contextmanager
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.db.backends import utils
|
||||
from django.db.backends.signals import connection_created
|
||||
from django.db.transaction import TransactionManagementError
|
||||
from django.db.utils import DatabaseError, DatabaseErrorWrapper
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.six.moves import _thread as thread
|
||||
|
||||
try:
|
||||
import pytz
|
||||
except ImportError:
|
||||
pytz = None
|
||||
|
||||
NO_DB_ALIAS = '__no_db__'
|
||||
|
||||
|
||||
|
@ -71,6 +78,39 @@ class BaseDatabaseWrapper(object):
|
|||
self.allow_thread_sharing = allow_thread_sharing
|
||||
self._thread_ident = thread.get_ident()
|
||||
|
||||
@cached_property
|
||||
def timezone(self):
|
||||
"""
|
||||
Time zone for datetimes stored as naive values in the database.
|
||||
|
||||
Returns a tzinfo object or None.
|
||||
|
||||
This is only needed when time zone support is enabled and the database
|
||||
doesn't support time zones. (When the database supports time zones,
|
||||
the adapter handles aware datetimes so Django doesn't need to.)
|
||||
"""
|
||||
if not settings.USE_TZ:
|
||||
return None
|
||||
elif self.features.supports_timezones:
|
||||
return None
|
||||
elif self.settings_dict['TIME_ZONE'] is None:
|
||||
return timezone.utc
|
||||
else:
|
||||
# Only this branch requires pytz.
|
||||
return pytz.timezone(self.settings_dict['TIME_ZONE'])
|
||||
|
||||
@cached_property
|
||||
def timezone_name(self):
|
||||
"""
|
||||
Name of the time zone of the database connection.
|
||||
"""
|
||||
if not settings.USE_TZ:
|
||||
return settings.TIME_ZONE
|
||||
elif self.settings_dict['TIME_ZONE'] is None:
|
||||
return 'UTC'
|
||||
else:
|
||||
return self.settings_dict['TIME_ZONE']
|
||||
|
||||
@property
|
||||
def queries_logged(self):
|
||||
return self.force_debug_cursor or settings.DEBUG
|
||||
|
@ -105,6 +145,8 @@ class BaseDatabaseWrapper(object):
|
|||
|
||||
def connect(self):
|
||||
"""Connects to the database. Assumes that the connection is closed."""
|
||||
# Check for invalid configurations.
|
||||
self.check_settings()
|
||||
# In case the previous connection was closed while in an atomic block
|
||||
self.in_atomic_block = False
|
||||
self.savepoint_ids = []
|
||||
|
@ -121,6 +163,21 @@ class BaseDatabaseWrapper(object):
|
|||
self.init_connection_state()
|
||||
connection_created.send(sender=self.__class__, connection=self)
|
||||
|
||||
def check_settings(self):
|
||||
if self.settings_dict['TIME_ZONE'] is not None:
|
||||
if not settings.USE_TZ:
|
||||
raise ImproperlyConfigured(
|
||||
"Connection '%s' cannot set TIME_ZONE because USE_TZ is "
|
||||
"False." % self.alias)
|
||||
elif self.features.supports_timezones:
|
||||
raise ImproperlyConfigured(
|
||||
"Connection '%s' cannot set TIME_ZONE because its engine "
|
||||
"handles time zones conversions natively." % self.alias)
|
||||
elif pytz is None:
|
||||
raise ImproperlyConfigured(
|
||||
"Connection '%s' cannot set TIME_ZONE because pytz isn't "
|
||||
"installed." % self.alias)
|
||||
|
||||
def ensure_connection(self):
|
||||
"""
|
||||
Guarantees that a connection to the database is established.
|
||||
|
|
|
@ -61,6 +61,8 @@ def adapt_datetime_warn_on_aware_datetime(value, conv):
|
|||
"probably from cursor.execute(). Update your code to pass a "
|
||||
"naive datetime in the database connection's time zone (UTC by "
|
||||
"default).", RemovedInDjango21Warning)
|
||||
# This doesn't account for the database connection's timezone,
|
||||
# which isn't known. (That's why this adapter is deprecated.)
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return Thing2Literal(value.strftime("%Y-%m-%d %H:%M:%S.%f"), conv)
|
||||
|
||||
|
|
|
@ -145,7 +145,7 @@ class DatabaseOperations(BaseDatabaseOperations):
|
|||
# MySQL doesn't support tz-aware datetimes
|
||||
if timezone.is_aware(value):
|
||||
if settings.USE_TZ:
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
value = timezone.make_naive(value, self.connection.timezone)
|
||||
else:
|
||||
raise ValueError("MySQL backend does not support timezone-aware datetimes when USE_TZ is False.")
|
||||
|
||||
|
@ -205,7 +205,7 @@ class DatabaseOperations(BaseDatabaseOperations):
|
|||
def convert_datetimefield_value(self, value, expression, connection, context):
|
||||
if value is not None:
|
||||
if settings.USE_TZ:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
value = timezone.make_aware(value, self.connection.timezone)
|
||||
return value
|
||||
|
||||
def convert_uuidfield_value(self, value, expression, connection, context):
|
||||
|
|
|
@ -196,7 +196,7 @@ WHEN (new.%(col_name)s IS NULL)
|
|||
def convert_datetimefield_value(self, value, expression, connection, context):
|
||||
if value is not None:
|
||||
if settings.USE_TZ:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
value = timezone.make_aware(value, self.connection.timezone)
|
||||
return value
|
||||
|
||||
def convert_datefield_value(self, value, expression, connection, context):
|
||||
|
@ -399,7 +399,7 @@ WHEN (new.%(col_name)s IS NULL)
|
|||
# cx_Oracle doesn't support tz-aware datetimes
|
||||
if timezone.is_aware(value):
|
||||
if settings.USE_TZ:
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
value = timezone.make_naive(value, self.connection.timezone)
|
||||
else:
|
||||
raise ValueError("Oracle backend does not support timezone-aware datetimes when USE_TZ is False.")
|
||||
|
||||
|
|
|
@ -153,7 +153,6 @@ class DatabaseWrapper(BaseDatabaseWrapper):
|
|||
settings_dict = self.settings_dict
|
||||
# None may be used to connect to the default 'postgres' db
|
||||
if settings_dict['NAME'] == '':
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
raise ImproperlyConfigured(
|
||||
"settings.DATABASES is improperly configured. "
|
||||
"Please supply the NAME value.")
|
||||
|
@ -195,13 +194,12 @@ class DatabaseWrapper(BaseDatabaseWrapper):
|
|||
def init_connection_state(self):
|
||||
self.connection.set_client_encoding('UTF8')
|
||||
|
||||
tz = self.settings_dict['TIME_ZONE']
|
||||
conn_tz = self.connection.get_parameter_status('TimeZone')
|
||||
conn_timezone_name = self.connection.get_parameter_status('TimeZone')
|
||||
|
||||
if conn_tz != tz:
|
||||
if conn_timezone_name != self.timezone_name:
|
||||
cursor = self.connection.cursor()
|
||||
try:
|
||||
cursor.execute(self.ops.set_time_zone_sql(), [tz])
|
||||
cursor.execute(self.ops.set_time_zone_sql(), [self.timezone_name])
|
||||
finally:
|
||||
cursor.close()
|
||||
# Commit after setting the time zone (see #17062)
|
||||
|
|
|
@ -58,6 +58,8 @@ def adapt_datetime_warn_on_aware_datetime(value):
|
|||
"probably from cursor.execute(). Update your code to pass a "
|
||||
"naive datetime in the database connection's time zone (UTC by "
|
||||
"default).", RemovedInDjango21Warning)
|
||||
# This doesn't account for the database connection's timezone,
|
||||
# which isn't known. (That's why this adapter is deprecated.)
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return value.isoformat(str(" "))
|
||||
|
||||
|
|
|
@ -120,7 +120,7 @@ class DatabaseOperations(BaseDatabaseOperations):
|
|||
# SQLite doesn't support tz-aware datetimes
|
||||
if timezone.is_aware(value):
|
||||
if settings.USE_TZ:
|
||||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
value = timezone.make_naive(value, self.connection.timezone)
|
||||
else:
|
||||
raise ValueError("SQLite backend does not support timezone-aware datetimes when USE_TZ is False.")
|
||||
|
||||
|
@ -156,7 +156,7 @@ class DatabaseOperations(BaseDatabaseOperations):
|
|||
if not isinstance(value, datetime.datetime):
|
||||
value = parse_datetime(value)
|
||||
if settings.USE_TZ:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
value = timezone.make_aware(value, self.connection.timezone)
|
||||
return value
|
||||
|
||||
def convert_datefield_value(self, value, expression, connection, context):
|
||||
|
|
|
@ -177,7 +177,7 @@ class ConnectionHandler(object):
|
|||
conn['ENGINE'] = 'django.db.backends.dummy'
|
||||
conn.setdefault('CONN_MAX_AGE', 0)
|
||||
conn.setdefault('OPTIONS', {})
|
||||
conn.setdefault('TIME_ZONE', 'UTC' if settings.USE_TZ else settings.TIME_ZONE)
|
||||
conn.setdefault('TIME_ZONE', None)
|
||||
for setting in ['NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']:
|
||||
conn.setdefault(setting, '')
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ import threading
|
|||
import time
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.signals import setting_changed
|
||||
from django.db import connections, router
|
||||
from django.db.utils import ConnectionRouter
|
||||
|
@ -62,19 +61,20 @@ def update_connections_time_zone(**kwargs):
|
|||
timezone.get_default_timezone.cache_clear()
|
||||
|
||||
# Reset the database connections' time zone
|
||||
if kwargs['setting'] == 'USE_TZ' and settings.TIME_ZONE != 'UTC':
|
||||
USE_TZ, TIME_ZONE = kwargs['value'], settings.TIME_ZONE
|
||||
elif kwargs['setting'] == 'TIME_ZONE' and not settings.USE_TZ:
|
||||
USE_TZ, TIME_ZONE = settings.USE_TZ, kwargs['value']
|
||||
else:
|
||||
# no need to change the database connnections' time zones
|
||||
return
|
||||
tz = 'UTC' if USE_TZ else TIME_ZONE
|
||||
for conn in connections.all():
|
||||
conn.settings_dict['TIME_ZONE'] = tz
|
||||
tz_sql = conn.ops.set_time_zone_sql()
|
||||
if tz_sql:
|
||||
conn.cursor().execute(tz_sql, [tz])
|
||||
if kwargs['setting'] in {'TIME_ZONE', 'USE_TZ'}:
|
||||
for conn in connections.all():
|
||||
try:
|
||||
del conn.timezone
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
del conn.timezone_name
|
||||
except AttributeError:
|
||||
pass
|
||||
tz_sql = conn.ops.set_time_zone_sql()
|
||||
if tz_sql:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(tz_sql, [conn.timezone_name])
|
||||
|
||||
|
||||
@receiver(setting_changed)
|
||||
|
|
|
@ -589,6 +589,41 @@ Default: ``''`` (Empty string)
|
|||
The port to use when connecting to the database. An empty string means the
|
||||
default port. Not used with SQLite.
|
||||
|
||||
.. setting:: DATABASE-TIME_ZONE
|
||||
|
||||
TIME_ZONE
|
||||
~~~~~~~~~
|
||||
|
||||
.. versionadded:: 1.9
|
||||
|
||||
Default: ``None``
|
||||
|
||||
A string representing the time zone for datetimes stored in this database
|
||||
(assuming that it doesn't support time zones) or ``None``. The same values are
|
||||
accepted as in the general :setting:`TIME_ZONE` setting.
|
||||
|
||||
This allows interacting with third-party databases that store datetimes in
|
||||
local time rather than UTC. To avoid issues around DST changes, you shouldn't
|
||||
set this option for databases managed by Django.
|
||||
|
||||
Setting this option requires installing pytz_.
|
||||
|
||||
When :setting:`USE_TZ` is ``True`` and the database doesn't support time zones
|
||||
(e.g. SQLite, MySQL, Oracle), Django reads and writes datetimes in local time
|
||||
according to this option if it is set and in UTC if it isn't.
|
||||
|
||||
When :setting:`USE_TZ` is ``True`` and the database supports time zones (e.g.
|
||||
PostgreSQL), it is an error to set this option.
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
Before Django 1.9, the PostgreSQL database backend accepted an
|
||||
undocumented ``TIME_ZONE`` option, which caused data corruption.
|
||||
|
||||
When :setting:`USE_TZ` is ``False``, it is an error to set this option.
|
||||
|
||||
.. _pytz: http://pytz.sourceforge.net/
|
||||
|
||||
.. setting:: USER
|
||||
|
||||
USER
|
||||
|
@ -2472,8 +2507,6 @@ to ensure your processes are running in the correct environment.
|
|||
|
||||
.. _list of time zones: http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
|
||||
.. _pytz: http://pytz.sourceforge.net/
|
||||
|
||||
.. setting:: USE_ETAGS
|
||||
|
||||
USE_ETAGS
|
||||
|
|
|
@ -201,6 +201,10 @@ Management Commands
|
|||
Models
|
||||
^^^^^^
|
||||
|
||||
* Database configuration gained a :setting:`TIME_ZONE <DATABASE-TIME_ZONE>`
|
||||
option for interacting with databases that store datetimes in local time and
|
||||
don't support time zones when :setting:`USE_TZ` is ``True``.
|
||||
|
||||
* Added the :meth:`RelatedManager.set()
|
||||
<django.db.models.fields.related.RelatedManager.set()>` method to the related
|
||||
managers created by ``ForeignKey``, ``GenericForeignKey``, and
|
||||
|
|
|
@ -9,16 +9,15 @@ Time zones
|
|||
Overview
|
||||
========
|
||||
|
||||
When support for time zones is enabled, Django stores datetime
|
||||
information in UTC in the database, uses time-zone-aware datetime objects
|
||||
internally, and translates them to the end user's time zone in templates and
|
||||
forms.
|
||||
When support for time zones is enabled, Django stores datetime information in
|
||||
UTC in the database, uses time-zone-aware datetime objects internally, and
|
||||
translates them to the end user's time zone in templates and forms.
|
||||
|
||||
This is handy if your users live in more than one time zone and you want to
|
||||
display datetime information according to each user's wall clock.
|
||||
|
||||
Even if your Web site is available in only one time zone, it's still good
|
||||
practice to store data in UTC in your database. One main reason is Daylight
|
||||
practice to store data in UTC in your database. The main reason is Daylight
|
||||
Saving Time (DST). Many countries have a system of DST, where clocks are moved
|
||||
forward in spring and backward in autumn. If you're working in local time,
|
||||
you're likely to encounter errors twice a year, when the transitions happen.
|
||||
|
@ -537,6 +536,14 @@ Setup
|
|||
Furthermore, if you want to support users in more than one time zone, pytz
|
||||
is the reference for time zone definitions.
|
||||
|
||||
4. **How do I interact with a database that stores datetimes in local time?**
|
||||
|
||||
Set the :setting:`TIME_ZONE <DATABASE-TIME_ZONE>` option to the appropriate
|
||||
time zone for this database in the :setting:`DATABASES` setting.
|
||||
|
||||
This is useful for connecting to a database that doesn't support time zones
|
||||
and that isn't managed by Django when :setting:`USE_TZ` is ``True``.
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
|
|
|
@ -222,6 +222,7 @@ class PostgreSQLTests(TestCase):
|
|||
databases = copy.deepcopy(settings.DATABASES)
|
||||
new_connections = ConnectionHandler(databases)
|
||||
new_connection = new_connections[DEFAULT_DB_ALIAS]
|
||||
|
||||
try:
|
||||
# Ensure the database default time zone is different than
|
||||
# the time zone in new_connection.settings_dict. We can
|
||||
|
@ -233,17 +234,22 @@ class PostgreSQLTests(TestCase):
|
|||
new_tz = 'Europe/Paris' if db_default_tz == 'UTC' else 'UTC'
|
||||
new_connection.close()
|
||||
|
||||
# Invalidate timezone name cache, because the setting_changed
|
||||
# handler cannot know about new_connection.
|
||||
del new_connection.timezone_name
|
||||
|
||||
# Fetch a new connection with the new_tz as default
|
||||
# time zone, run a query and rollback.
|
||||
new_connection.settings_dict['TIME_ZONE'] = new_tz
|
||||
new_connection.set_autocommit(False)
|
||||
cursor = new_connection.cursor()
|
||||
new_connection.rollback()
|
||||
with self.settings(TIME_ZONE=new_tz):
|
||||
new_connection.set_autocommit(False)
|
||||
cursor = new_connection.cursor()
|
||||
new_connection.rollback()
|
||||
|
||||
# Now let's see if the rollback rolled back the SET TIME ZONE.
|
||||
cursor.execute("SHOW TIMEZONE")
|
||||
tz = cursor.fetchone()[0]
|
||||
self.assertEqual(new_tz, tz)
|
||||
|
||||
# Now let's see if the rollback rolled back the SET TIME ZONE.
|
||||
cursor.execute("SHOW TIMEZONE")
|
||||
tz = cursor.fetchone()[0]
|
||||
self.assertEqual(new_tz, tz)
|
||||
finally:
|
||||
new_connection.close()
|
||||
|
||||
|
|
|
@ -9,15 +9,17 @@ from xml.dom.minidom import parseString
|
|||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import serializers
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import connection
|
||||
from django.db import connection, connections
|
||||
from django.db.models import Max, Min
|
||||
from django.http import HttpRequest
|
||||
from django.template import (
|
||||
Context, RequestContext, Template, TemplateSyntaxError, context_processors,
|
||||
)
|
||||
from django.test import (
|
||||
TestCase, override_settings, skipIfDBFeature, skipUnlessDBFeature,
|
||||
TestCase, TransactionTestCase, override_settings, skipIfDBFeature,
|
||||
skipUnlessDBFeature,
|
||||
)
|
||||
from django.test.utils import requires_tz_support
|
||||
from django.utils import six, timezone
|
||||
|
@ -620,6 +622,67 @@ class NewDatabaseTests(TestCase):
|
|||
self.assertEqual(e.dt, None)
|
||||
|
||||
|
||||
# TODO: chaining @skipIfDBFeature and @skipUnlessDBFeature doesn't work!
|
||||
@skipIfDBFeature('supports_timezones')
|
||||
@skipUnlessDBFeature('test_db_allows_multiple_connections')
|
||||
@override_settings(TIME_ZONE='Africa/Nairobi', USE_TZ=True)
|
||||
class ForcedTimeZoneDatabaseTests(TransactionTestCase):
|
||||
"""
|
||||
Test the TIME_ZONE database configuration parameter.
|
||||
|
||||
Since this involves reading and writing to the same database through two
|
||||
connections, this is a TransactionTestCase.
|
||||
"""
|
||||
|
||||
available_apps = ['timezones']
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(ForcedTimeZoneDatabaseTests, cls).setUpClass()
|
||||
connections.databases['tz'] = connections.databases['default'].copy()
|
||||
connections.databases['tz']['TIME_ZONE'] = 'Asia/Bangkok'
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
connections['tz'].close()
|
||||
del connections['tz']
|
||||
del connections.databases['tz']
|
||||
super(ForcedTimeZoneDatabaseTests, cls).tearDownClass()
|
||||
|
||||
def test_read_datetime(self):
|
||||
fake_dt = datetime.datetime(2011, 9, 1, 17, 20, 30, tzinfo=UTC)
|
||||
Event.objects.create(dt=fake_dt)
|
||||
|
||||
event = Event.objects.using('tz').get()
|
||||
dt = datetime.datetime(2011, 9, 1, 10, 20, 30, tzinfo=UTC)
|
||||
self.assertEqual(event.dt, dt)
|
||||
|
||||
def test_write_datetime(self):
|
||||
dt = datetime.datetime(2011, 9, 1, 10, 20, 30, tzinfo=UTC)
|
||||
Event.objects.using('tz').create(dt=dt)
|
||||
|
||||
event = Event.objects.get()
|
||||
fake_dt = datetime.datetime(2011, 9, 1, 17, 20, 30, tzinfo=UTC)
|
||||
self.assertEqual(event.dt, fake_dt)
|
||||
|
||||
|
||||
@skipUnlessDBFeature('supports_timezones')
|
||||
@override_settings(TIME_ZONE='Africa/Nairobi', USE_TZ=True)
|
||||
class UnsupportedTimeZoneDatabaseTests(TestCase):
|
||||
|
||||
def test_time_zone_parameter_not_supported_if_database_supports_timezone(self):
|
||||
connections.databases['tz'] = connections.databases['default'].copy()
|
||||
connections.databases['tz']['TIME_ZONE'] = 'Asia/Bangkok'
|
||||
tz_conn = connections['tz']
|
||||
try:
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
tz_conn.cursor()
|
||||
finally:
|
||||
connections['tz'].close() # in case the test fails
|
||||
del connections['tz']
|
||||
del connections.databases['tz']
|
||||
|
||||
|
||||
@override_settings(TIME_ZONE='Africa/Nairobi')
|
||||
class SerializationTests(TestCase):
|
||||
|
||||
|
|
Loading…
Reference in New Issue