diff --git a/django/db/__init__.py b/django/db/__init__.py index 32da61d680..a98121b4f9 100644 --- a/django/db/__init__.py +++ b/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): diff --git a/django/db/backends/base/base.py b/django/db/backends/base/base.py index ad6b4f3df1..2390cd7940 100644 --- a/django/db/backends/base/base.py +++ b/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. diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index e603c75eae..4c993c05d7 100644 --- a/django/db/backends/mysql/base.py +++ b/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) diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index 886e118fb0..fa0e1edcab 100644 --- a/django/db/backends/mysql/operations.py +++ b/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): diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index fe95da1303..1d3945227c 100644 --- a/django/db/backends/oracle/operations.py +++ b/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.") diff --git a/django/db/backends/postgresql_psycopg2/base.py b/django/db/backends/postgresql_psycopg2/base.py index a76ca3c0df..af616e48c4 100644 --- a/django/db/backends/postgresql_psycopg2/base.py +++ b/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) diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index dea41a8ca6..5da6bbd1fb 100644 --- a/django/db/backends/sqlite3/base.py +++ b/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(" ")) diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py index 1e5b2759af..02d688b63d 100644 --- a/django/db/backends/sqlite3/operations.py +++ b/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): diff --git a/django/db/utils.py b/django/db/utils.py index 2fb42a7674..534a4d5c20 100644 --- a/django/db/utils.py +++ b/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, '') diff --git a/django/test/signals.py b/django/test/signals.py index 0c2bf5201c..a7774adb08 100644 --- a/django/test/signals.py +++ b/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) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 74d962f4bb..58f4049542 100644 --- a/docs/ref/settings.txt +++ b/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 diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index 41dadd295c..ebf2ce45c4 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -201,6 +201,10 @@ Management Commands Models ^^^^^^ +* Database configuration gained a :setting:`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() ` method to the related managers created by ``ForeignKey``, ``GenericForeignKey``, and diff --git a/docs/topics/i18n/timezones.txt b/docs/topics/i18n/timezones.txt index df9d1decfc..3b7fbff52f 100644 --- a/docs/topics/i18n/timezones.txt +++ b/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 ` 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 --------------- diff --git a/tests/backends/tests.py b/tests/backends/tests.py index b735071ff9..0773c9fd64 100644 --- a/tests/backends/tests.py +++ b/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() diff --git a/tests/timezones/tests.py b/tests/timezones/tests.py index dc2d358364..1fd12c5f3a 100644 --- a/tests/timezones/tests.py +++ b/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):