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:
Aymeric Augustin 2015-05-02 21:56:53 +02:00
parent 54026f1e8d
commit ed83881e64
15 changed files with 218 additions and 47 deletions

View File

@ -20,10 +20,9 @@ router = ConnectionRouter()
# `connection`, `DatabaseError` and `IntegrityError` are convenient aliases # `connection`, `DatabaseError` and `IntegrityError` are convenient aliases
# for backend bits. # for backend bits.
# DatabaseWrapper.__init__() takes a dictionary, not a settings module, so # DatabaseWrapper.__init__() takes a dictionary, not a settings module, so we
# we manually create the dictionary from the settings, passing only the # manually create the dictionary from the settings, passing only the settings
# settings that the database backends care about. Note that TIME_ZONE is used # that the database backends care about.
# by the PostgreSQL backends.
# We load all these up for backwards compatibility, you should use # We load all these up for backwards compatibility, you should use
# connections['default'] instead. # connections['default'] instead.
class DefaultConnectionProxy(object): class DefaultConnectionProxy(object):

View File

@ -4,14 +4,21 @@ from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import DEFAULT_DB_ALIAS from django.db import DEFAULT_DB_ALIAS
from django.db.backends import utils from django.db.backends import utils
from django.db.backends.signals import connection_created from django.db.backends.signals import connection_created
from django.db.transaction import TransactionManagementError from django.db.transaction import TransactionManagementError
from django.db.utils import DatabaseError, DatabaseErrorWrapper from django.db.utils import DatabaseError, DatabaseErrorWrapper
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.six.moves import _thread as thread from django.utils.six.moves import _thread as thread
try:
import pytz
except ImportError:
pytz = None
NO_DB_ALIAS = '__no_db__' NO_DB_ALIAS = '__no_db__'
@ -71,6 +78,39 @@ class BaseDatabaseWrapper(object):
self.allow_thread_sharing = allow_thread_sharing self.allow_thread_sharing = allow_thread_sharing
self._thread_ident = thread.get_ident() 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 @property
def queries_logged(self): def queries_logged(self):
return self.force_debug_cursor or settings.DEBUG return self.force_debug_cursor or settings.DEBUG
@ -105,6 +145,8 @@ class BaseDatabaseWrapper(object):
def connect(self): def connect(self):
"""Connects to the database. Assumes that the connection is closed.""" """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 # In case the previous connection was closed while in an atomic block
self.in_atomic_block = False self.in_atomic_block = False
self.savepoint_ids = [] self.savepoint_ids = []
@ -121,6 +163,21 @@ class BaseDatabaseWrapper(object):
self.init_connection_state() self.init_connection_state()
connection_created.send(sender=self.__class__, connection=self) 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): def ensure_connection(self):
""" """
Guarantees that a connection to the database is established. Guarantees that a connection to the database is established.

View File

@ -61,6 +61,8 @@ def adapt_datetime_warn_on_aware_datetime(value, conv):
"probably from cursor.execute(). Update your code to pass a " "probably from cursor.execute(). Update your code to pass a "
"naive datetime in the database connection's time zone (UTC by " "naive datetime in the database connection's time zone (UTC by "
"default).", RemovedInDjango21Warning) "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) value = value.astimezone(timezone.utc).replace(tzinfo=None)
return Thing2Literal(value.strftime("%Y-%m-%d %H:%M:%S.%f"), conv) return Thing2Literal(value.strftime("%Y-%m-%d %H:%M:%S.%f"), conv)

View File

@ -145,7 +145,7 @@ class DatabaseOperations(BaseDatabaseOperations):
# MySQL doesn't support tz-aware datetimes # MySQL doesn't support tz-aware datetimes
if timezone.is_aware(value): if timezone.is_aware(value):
if settings.USE_TZ: if settings.USE_TZ:
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = timezone.make_naive(value, self.connection.timezone)
else: else:
raise ValueError("MySQL backend does not support timezone-aware datetimes when USE_TZ is False.") 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): def convert_datetimefield_value(self, value, expression, connection, context):
if value is not None: if value is not None:
if settings.USE_TZ: if settings.USE_TZ:
value = value.replace(tzinfo=timezone.utc) value = timezone.make_aware(value, self.connection.timezone)
return value return value
def convert_uuidfield_value(self, value, expression, connection, context): def convert_uuidfield_value(self, value, expression, connection, context):

View File

@ -196,7 +196,7 @@ WHEN (new.%(col_name)s IS NULL)
def convert_datetimefield_value(self, value, expression, connection, context): def convert_datetimefield_value(self, value, expression, connection, context):
if value is not None: if value is not None:
if settings.USE_TZ: if settings.USE_TZ:
value = value.replace(tzinfo=timezone.utc) value = timezone.make_aware(value, self.connection.timezone)
return value return value
def convert_datefield_value(self, value, expression, connection, context): 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 # cx_Oracle doesn't support tz-aware datetimes
if timezone.is_aware(value): if timezone.is_aware(value):
if settings.USE_TZ: if settings.USE_TZ:
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = timezone.make_naive(value, self.connection.timezone)
else: else:
raise ValueError("Oracle backend does not support timezone-aware datetimes when USE_TZ is False.") raise ValueError("Oracle backend does not support timezone-aware datetimes when USE_TZ is False.")

View File

@ -153,7 +153,6 @@ class DatabaseWrapper(BaseDatabaseWrapper):
settings_dict = self.settings_dict settings_dict = self.settings_dict
# None may be used to connect to the default 'postgres' db # None may be used to connect to the default 'postgres' db
if settings_dict['NAME'] == '': if settings_dict['NAME'] == '':
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured( raise ImproperlyConfigured(
"settings.DATABASES is improperly configured. " "settings.DATABASES is improperly configured. "
"Please supply the NAME value.") "Please supply the NAME value.")
@ -195,13 +194,12 @@ class DatabaseWrapper(BaseDatabaseWrapper):
def init_connection_state(self): def init_connection_state(self):
self.connection.set_client_encoding('UTF8') self.connection.set_client_encoding('UTF8')
tz = self.settings_dict['TIME_ZONE'] conn_timezone_name = self.connection.get_parameter_status('TimeZone')
conn_tz = self.connection.get_parameter_status('TimeZone')
if conn_tz != tz: if conn_timezone_name != self.timezone_name:
cursor = self.connection.cursor() cursor = self.connection.cursor()
try: try:
cursor.execute(self.ops.set_time_zone_sql(), [tz]) cursor.execute(self.ops.set_time_zone_sql(), [self.timezone_name])
finally: finally:
cursor.close() cursor.close()
# Commit after setting the time zone (see #17062) # Commit after setting the time zone (see #17062)

View File

@ -58,6 +58,8 @@ def adapt_datetime_warn_on_aware_datetime(value):
"probably from cursor.execute(). Update your code to pass a " "probably from cursor.execute(). Update your code to pass a "
"naive datetime in the database connection's time zone (UTC by " "naive datetime in the database connection's time zone (UTC by "
"default).", RemovedInDjango21Warning) "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) value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value.isoformat(str(" ")) return value.isoformat(str(" "))

View File

@ -120,7 +120,7 @@ class DatabaseOperations(BaseDatabaseOperations):
# SQLite doesn't support tz-aware datetimes # SQLite doesn't support tz-aware datetimes
if timezone.is_aware(value): if timezone.is_aware(value):
if settings.USE_TZ: if settings.USE_TZ:
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = timezone.make_naive(value, self.connection.timezone)
else: else:
raise ValueError("SQLite backend does not support timezone-aware datetimes when USE_TZ is False.") 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): if not isinstance(value, datetime.datetime):
value = parse_datetime(value) value = parse_datetime(value)
if settings.USE_TZ: if settings.USE_TZ:
value = value.replace(tzinfo=timezone.utc) value = timezone.make_aware(value, self.connection.timezone)
return value return value
def convert_datefield_value(self, value, expression, connection, context): def convert_datefield_value(self, value, expression, connection, context):

View File

@ -177,7 +177,7 @@ class ConnectionHandler(object):
conn['ENGINE'] = 'django.db.backends.dummy' conn['ENGINE'] = 'django.db.backends.dummy'
conn.setdefault('CONN_MAX_AGE', 0) conn.setdefault('CONN_MAX_AGE', 0)
conn.setdefault('OPTIONS', {}) 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']: for setting in ['NAME', 'USER', 'PASSWORD', 'HOST', 'PORT']:
conn.setdefault(setting, '') conn.setdefault(setting, '')

View File

@ -3,7 +3,6 @@ import threading
import time import time
import warnings import warnings
from django.conf import settings
from django.core.signals import setting_changed from django.core.signals import setting_changed
from django.db import connections, router from django.db import connections, router
from django.db.utils import ConnectionRouter from django.db.utils import ConnectionRouter
@ -62,19 +61,20 @@ def update_connections_time_zone(**kwargs):
timezone.get_default_timezone.cache_clear() timezone.get_default_timezone.cache_clear()
# Reset the database connections' time zone # Reset the database connections' time zone
if kwargs['setting'] == 'USE_TZ' and settings.TIME_ZONE != 'UTC': if kwargs['setting'] in {'TIME_ZONE', 'USE_TZ'}:
USE_TZ, TIME_ZONE = kwargs['value'], settings.TIME_ZONE for conn in connections.all():
elif kwargs['setting'] == 'TIME_ZONE' and not settings.USE_TZ: try:
USE_TZ, TIME_ZONE = settings.USE_TZ, kwargs['value'] del conn.timezone
else: except AttributeError:
# no need to change the database connnections' time zones pass
return try:
tz = 'UTC' if USE_TZ else TIME_ZONE del conn.timezone_name
for conn in connections.all(): except AttributeError:
conn.settings_dict['TIME_ZONE'] = tz pass
tz_sql = conn.ops.set_time_zone_sql() tz_sql = conn.ops.set_time_zone_sql()
if tz_sql: if tz_sql:
conn.cursor().execute(tz_sql, [tz]) with conn.cursor() as cursor:
cursor.execute(tz_sql, [conn.timezone_name])
@receiver(setting_changed) @receiver(setting_changed)

View File

@ -589,6 +589,41 @@ Default: ``''`` (Empty string)
The port to use when connecting to the database. An empty string means the The port to use when connecting to the database. An empty string means the
default port. Not used with SQLite. 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 .. setting:: USER
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 .. _list of time zones: http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
.. _pytz: http://pytz.sourceforge.net/
.. setting:: USE_ETAGS .. setting:: USE_ETAGS
USE_ETAGS USE_ETAGS

View File

@ -201,6 +201,10 @@ Management Commands
Models 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() * Added the :meth:`RelatedManager.set()
<django.db.models.fields.related.RelatedManager.set()>` method to the related <django.db.models.fields.related.RelatedManager.set()>` method to the related
managers created by ``ForeignKey``, ``GenericForeignKey``, and managers created by ``ForeignKey``, ``GenericForeignKey``, and

View File

@ -9,16 +9,15 @@ Time zones
Overview Overview
======== ========
When support for time zones is enabled, Django stores datetime When support for time zones is enabled, Django stores datetime information in
information in UTC in the database, uses time-zone-aware datetime objects UTC in the database, uses time-zone-aware datetime objects internally, and
internally, and translates them to the end user's time zone in templates and translates them to the end user's time zone in templates and forms.
forms.
This is handy if your users live in more than one time zone and you want to 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. 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 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 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, 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. 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 Furthermore, if you want to support users in more than one time zone, pytz
is the reference for time zone definitions. 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 Troubleshooting
--------------- ---------------

View File

@ -222,6 +222,7 @@ class PostgreSQLTests(TestCase):
databases = copy.deepcopy(settings.DATABASES) databases = copy.deepcopy(settings.DATABASES)
new_connections = ConnectionHandler(databases) new_connections = ConnectionHandler(databases)
new_connection = new_connections[DEFAULT_DB_ALIAS] new_connection = new_connections[DEFAULT_DB_ALIAS]
try: try:
# Ensure the database default time zone is different than # Ensure the database default time zone is different than
# the time zone in new_connection.settings_dict. We can # 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_tz = 'Europe/Paris' if db_default_tz == 'UTC' else 'UTC'
new_connection.close() 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 # Fetch a new connection with the new_tz as default
# time zone, run a query and rollback. # time zone, run a query and rollback.
new_connection.settings_dict['TIME_ZONE'] = new_tz with self.settings(TIME_ZONE=new_tz):
new_connection.set_autocommit(False) new_connection.set_autocommit(False)
cursor = new_connection.cursor() cursor = new_connection.cursor()
new_connection.rollback() 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: finally:
new_connection.close() new_connection.close()

View File

@ -9,15 +9,17 @@ from xml.dom.minidom import parseString
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import serializers from django.core import serializers
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse 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.db.models import Max, Min
from django.http import HttpRequest from django.http import HttpRequest
from django.template import ( from django.template import (
Context, RequestContext, Template, TemplateSyntaxError, context_processors, Context, RequestContext, Template, TemplateSyntaxError, context_processors,
) )
from django.test import ( 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.test.utils import requires_tz_support
from django.utils import six, timezone from django.utils import six, timezone
@ -620,6 +622,67 @@ class NewDatabaseTests(TestCase):
self.assertEqual(e.dt, None) 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') @override_settings(TIME_ZONE='Africa/Nairobi')
class SerializationTests(TestCase): class SerializationTests(TestCase):