Django 1.11 support (#463)

* Added Django 1.11 to Travis

- related to #435
- 😉 to tomturner/django-tenants#120

* Replace direct use of import_module by proper load_backend

Handle new 'django.db.backends.postgresql' name (Django 1.9)

* Fix 'default' already present in _fields in Django 1.11

* Fix not existing access to original_backend.DatabaseError (Django 1.11)

Removed exports because django.db.utils.DatabaseError and
django.db.utils.IntegrityError must be used instead.

This change must be stated in release notes.

* The signature of DatabaseWrapper._cursor has changed in Django 1.11

Use the public API

* Add missing allowed hosts for tests

* Don't override default user or blocks Unix socket DB connection

* Updated tox.ini for Django 1.11rc1

* Properly add '.test.com' to ALLOWED_HOSTS in test cases

* Internal tests don't use TenantTestCase so set allowed hosts manually

* Drop Django 1.9 support

Django 1.8 is still within LTS until the end of 2017.
This commit is contained in:
Stéphane Raimbault 2017-04-12 07:06:19 +02:00 committed by Adnan Umer
parent 26692945c1
commit 36e1ba3800
7 changed files with 69 additions and 33 deletions

View File

@ -14,8 +14,8 @@ script:
- tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} - tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./}
env: env:
- DJANGO=1.8 - DJANGO=1.8
- DJANGO=1.9
- DJANGO=1.10 - DJANGO=1.10
- DJANGO=1.11
deploy: deploy:
provider: pypi provider: pypi
user: bcarneiro user: bcarneiro

View File

@ -82,10 +82,10 @@ DATABASES = {
'default': { 'default': {
'ENGINE': 'tenant_schemas.postgresql_backend', 'ENGINE': 'tenant_schemas.postgresql_backend',
'NAME': os.environ.get('PG_NAME', 'dts_test_project'), 'NAME': os.environ.get('PG_NAME', 'dts_test_project'),
'USER': os.environ.get('PG_USER', 'postgres'), 'USER': os.environ.get('PG_USER'),
'PASSWORD': os.environ.get('PG_PASSWORD', 'root'), 'PASSWORD': os.environ.get('PG_PASSWORD'),
'HOST': os.environ.get('PG_HOST', 'localhost'), 'HOST': os.environ.get('PG_HOST'),
'PORT': int(os.environ.get('PG_PORT', '5432')), 'PORT': int(os.environ.get('PG_PORT')) if os.environ.get('PG_PORT') else None,
} }
} }

View File

@ -1,21 +1,17 @@
import re import re
import warnings import warnings
from django.conf import settings
try:
# Django versions >= 1.9
from django.utils.module_loading import import_module
except ImportError:
# Django versions < 1.9
from django.utils.importlib import import_module
from django.core.exceptions import ImproperlyConfigured, ValidationError
from tenant_schemas.utils import get_public_schema_name, get_limit_set_calls
from tenant_schemas.postgresql_backend.introspection import DatabaseSchemaIntrospection
import django.db.utils
import psycopg2 import psycopg2
ORIGINAL_BACKEND = getattr(settings, 'ORIGINAL_BACKEND', 'django.db.backends.postgresql_psycopg2') from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, ValidationError
import django.db.utils
original_backend = import_module(ORIGINAL_BACKEND + '.base') from tenant_schemas.utils import get_public_schema_name, get_limit_set_calls
from tenant_schemas.postgresql_backend.introspection import DatabaseSchemaIntrospection
ORIGINAL_BACKEND = getattr(settings, 'ORIGINAL_BACKEND', 'django.db.backends.postgresql_psycopg2')
# Django 1.9+ takes care to rename the default backend to 'django.db.backends.postgresql'
original_backend = django.db.utils.load_backend(ORIGINAL_BACKEND)
EXTRA_SEARCH_PATHS = getattr(settings, 'PG_EXTRA_SEARCH_PATHS', []) EXTRA_SEARCH_PATHS = getattr(settings, 'PG_EXTRA_SEARCH_PATHS', [])
@ -109,12 +105,16 @@ class DatabaseWrapper(original_backend.DatabaseWrapper):
category=DeprecationWarning) category=DeprecationWarning)
return self.tenant return self.tenant
def _cursor(self): def _cursor(self, name=None):
""" """
Here it happens. We hope every Django db operation using PostgreSQL Here it happens. We hope every Django db operation using PostgreSQL
must go through this to get the cursor handle. We change the path. must go through this to get the cursor handle. We change the path.
""" """
cursor = super(DatabaseWrapper, self)._cursor() if name:
# Only supported and required by Django 1.11 (server-side cursor)
cursor = super(DatabaseWrapper, self)._cursor(name=name)
else:
cursor = super(DatabaseWrapper, self)._cursor()
# optionally limit the number of executions - under load, the execution # optionally limit the number of executions - under load, the execution
# of `set search_path` can be quite time consuming # of `set search_path` can be quite time consuming
@ -137,16 +137,28 @@ class DatabaseWrapper(original_backend.DatabaseWrapper):
search_paths = [self.schema_name] search_paths = [self.schema_name]
search_paths.extend(EXTRA_SEARCH_PATHS) search_paths.extend(EXTRA_SEARCH_PATHS)
if name:
# Named cursor can only be used once
cursor_for_search_path = self.connection.cursor()
else:
# Reuse
cursor_for_search_path = cursor
# In the event that an error already happened in this transaction and we are going # In the event that an error already happened in this transaction and we are going
# to rollback we should just ignore database error when setting the search_path # to rollback we should just ignore database error when setting the search_path
# if the next instruction is not a rollback it will just fail also, so # if the next instruction is not a rollback it will just fail also, so
# we do not have to worry that it's not the good one # we do not have to worry that it's not the good one
try: try:
cursor.execute('SET search_path = {0}'.format(','.join(search_paths))) cursor_for_search_path.execute('SET search_path = {0}'.format(','.join(search_paths)))
except (django.db.utils.DatabaseError, psycopg2.InternalError): except (django.db.utils.DatabaseError, psycopg2.InternalError):
self.search_path_set = False self.search_path_set = False
else: else:
self.search_path_set = True self.search_path_set = True
if name:
cursor_for_search_path.close()
return cursor return cursor
@ -157,10 +169,3 @@ class FakeTenant:
""" """
def __init__(self, schema_name): def __init__(self, schema_name):
self.schema_name = schema_name self.schema_name = schema_name
if ORIGINAL_BACKEND == "django.contrib.gis.db.backends.postgis":
DatabaseError = django.db.utils.DatabaseError
IntegrityError = psycopg2.IntegrityError
else:
DatabaseError = original_backend.DatabaseError
IntegrityError = original_backend.IntegrityError

View File

@ -7,7 +7,11 @@ from django.db.backends.base.introspection import (
) )
from django.utils.encoding import force_text from django.utils.encoding import force_text
FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('default',)) fields = FieldInfo._fields
if 'default' not in fields:
fields += ('default',)
FieldInfo = namedtuple('FieldInfo', fields)
class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): class DatabaseSchemaIntrospection(BaseDatabaseIntrospection):

View File

@ -1,15 +1,30 @@
from django.core.management import call_command from django.core.management import call_command
from django.conf import settings
from django.db import connection from django.db import connection
from django.test import TestCase from django.test import TestCase
from tenant_schemas.utils import get_public_schema_name from tenant_schemas.utils import get_public_schema_name
from tenant_schemas.utils import get_tenant_model from tenant_schemas.utils import get_tenant_model
ALLOWED_TEST_DOMAIN = '.test.com'
class TenantTestCase(TestCase): class TenantTestCase(TestCase):
@classmethod
def add_allowed_test_domain(cls):
# ALLOWED_HOSTS is a special setting of Django setup_test_environment so we can't modify it with helpers
if ALLOWED_TEST_DOMAIN not in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS += [ALLOWED_TEST_DOMAIN]
@classmethod
def remove_allowed_test_domain(cls):
if ALLOWED_TEST_DOMAIN in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS.remove(ALLOWED_TEST_DOMAIN)
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.sync_shared() cls.sync_shared()
cls.add_allowed_test_domain()
tenant_domain = 'tenant.test.com' tenant_domain = 'tenant.test.com'
cls.tenant = get_tenant_model()(domain_url=tenant_domain, schema_name='test') cls.tenant = get_tenant_model()(domain_url=tenant_domain, schema_name='test')
cls.tenant.save(verbosity=0) # todo: is there any way to get the verbosity from the test command here? cls.tenant.save(verbosity=0) # todo: is there any way to get the verbosity from the test command here?
@ -21,6 +36,7 @@ class TenantTestCase(TestCase):
connection.set_schema_to_public() connection.set_schema_to_public()
cls.tenant.delete() cls.tenant.delete()
cls.remove_allowed_test_domain()
cursor = connection.cursor() cursor = connection.cursor()
cursor.execute('DROP SCHEMA IF EXISTS test CASCADE') cursor.execute('DROP SCHEMA IF EXISTS test CASCADE')
@ -33,10 +49,10 @@ class TenantTestCase(TestCase):
class FastTenantTestCase(TenantTestCase): class FastTenantTestCase(TenantTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.sync_shared() cls.sync_shared()
cls.add_allowed_test_domain()
tenant_domain = 'tenant.test.com' tenant_domain = 'tenant.test.com'
TenantModel = get_tenant_model() TenantModel = get_tenant_model()
@ -51,3 +67,4 @@ class FastTenantTestCase(TenantTestCase):
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
connection.set_schema_to_public() connection.set_schema_to_public()
cls.remove_allowed_test_domain()

View File

@ -20,6 +20,8 @@ class BaseTestCase(TestCase):
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.auth', ) 'django.contrib.auth', )
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
if '.test.com' not in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS += ['.test.com']
# Django calls syncdb by default for the test database, but we want # Django calls syncdb by default for the test database, but we want
# a blank public schema for this set of tests. # a blank public schema for this set of tests.
@ -29,6 +31,13 @@ class BaseTestCase(TestCase):
% (get_public_schema_name(), get_public_schema_name())) % (get_public_schema_name(), get_public_schema_name()))
super(BaseTestCase, cls).setUpClass() super(BaseTestCase, cls).setUpClass()
@classmethod
def tearDownClass(cls):
super(BaseTestCase, cls).tearDownClass()
if '.test.com' in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS.remove('.test.com')
def setUp(self): def setUp(self):
connection.set_schema_to_public() connection.set_schema_to_public()
super(BaseTestCase, self).setUp() super(BaseTestCase, self).setUp()

View File

@ -1,21 +1,22 @@
[tox] [tox]
envlist = py{27,35}-dj{18,19,110} envlist = py{27,35}-dj{18,19,110,111}
[testenv] [testenv]
usedevelop = True usedevelop = True
deps = deps =
coverage coverage
mock mock
tblib tblib
dj18: Django~=1.8.0 dj18: Django~=1.8.0
dj19: Django~=1.9.0 dj19: Django~=1.9.0
dj110: Django~=1.10.0 dj110: Django~=1.10.0
dj111: Django~=1.11rc1
changedir = dts_test_project changedir = dts_test_project
passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT
commands = commands =
coverage run manage.py test --noinput {posargs:tenant_schemas} coverage run manage.py test --noinput {posargs:tenant_schemas}
coverage report -m --include=../tenant_schemas/* coverage report -m --include=../tenant_schemas/*