Browse Source

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.
Aymeric Augustin 10 years ago
parent
commit
ed83881e64

+ 3 - 4
django/db/__init__.py

@@ -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):

+ 57 - 0
django/db/backends/base/base.py

@@ -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.

+ 2 - 0
django/db/backends/mysql/base.py

@@ -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)
 

+ 2 - 2
django/db/backends/mysql/operations.py

@@ -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):

+ 2 - 2
django/db/backends/oracle/operations.py

@@ -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.")
 

+ 3 - 5
django/db/backends/postgresql_psycopg2/base.py

@@ -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)

+ 2 - 0
django/db/backends/sqlite3/base.py

@@ -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(" "))
 

+ 2 - 2
django/db/backends/sqlite3/operations.py

@@ -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):

+ 1 - 1
django/db/utils.py

@@ -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, '')
 

+ 14 - 14
django/test/signals.py

@@ -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)

+ 35 - 2
docs/ref/settings.txt

@@ -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

+ 4 - 0
docs/releases/1.9.txt

@@ -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

+ 12 - 5
docs/topics/i18n/timezones.txt

@@ -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
 ---------------
 

+ 14 - 8
tests/backends/tests.py

@@ -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()
 

+ 65 - 2
tests/timezones/tests.py

@@ -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):