From c61aca19e4f54d6d6ad1548447ee4eca04335c6f Mon Sep 17 00:00:00 2001 From: Krzysztof Nazarewski Date: Tue, 10 Nov 2015 15:18:16 +0100 Subject: [PATCH 001/157] Added AppConfig handling to tenant_schemas.utils.app_labels --- tenant_schemas/utils.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 11a7ac9..5d8961c 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -2,10 +2,11 @@ from contextlib import contextmanager from django.conf import settings from django.db import connection try: - from django.apps import apps + from django.apps import apps, AppConfig get_model = apps.get_model except ImportError: from django.db.models.loading import get_model + AppConfig = None from django.core import mail @@ -102,9 +103,39 @@ def schema_exists(schema_name): return exists - def app_labels(apps_list): """ Returns a list of app labels of the given apps_list + + + AppConfig handling test: + >>> import sys + >>> import types + + Wrapping test in a function so we can return from it on Django <1.7 + >>> def run_test(): + ... label = 'test_app1' + ... + ... if AppConfig is None: + ... assert app_labels(['test_apps.' + label]) == [label] + ... return + ... + ... name = 'test_app' + ... sys.modules[name] = types.ModuleType(name) + ... + ... name = 'test_app.apps' + ... sys.modules[name] = types.ModuleType(name) + ... # noinspection PyPep8Naming + ... sys.modules[name].TestAppConfig = type( + ... 'TestAppConfig', + ... (AppConfig,), + ... dict(name='test_app', label=label, path='/tmp') + ... ) + ... assert app_labels(['test_app.apps.TestAppConfig']) == [label] + + >>> run_test() """ - return [app.split('.')[-1] for app in apps_list] + if AppConfig is None: + return [app.split('.')[-1] for app in apps_list] + return [AppConfig.create(app).label for app in apps_list] + From d337ce663b8d87746c476bd1ce79527e62134d7c Mon Sep 17 00:00:00 2001 From: Krzysztof Nazarewski Date: Mon, 16 Nov 2015 16:41:14 +0100 Subject: [PATCH 002/157] moved app_labels() test from docstring to separate TestCase and extended it, --- tenant_schemas/tests/test_utils.py | 97 ++++++++++++++++++++++++++++++ tenant_schemas/utils.py | 32 ++-------- 2 files changed, 101 insertions(+), 28 deletions(-) create mode 100644 tenant_schemas/tests/test_utils.py diff --git a/tenant_schemas/tests/test_utils.py b/tenant_schemas/tests/test_utils.py new file mode 100644 index 0000000..931d5ce --- /dev/null +++ b/tenant_schemas/tests/test_utils.py @@ -0,0 +1,97 @@ +from __future__ import absolute_import + +import sys +import types + +import django +from django.test import TestCase + +from tenant_schemas import utils + + +class AppLabelsTestCase(TestCase): + def setUp(self): + self._modules = set() + + def tearDown(self): + for name in self._modules: + sys.modules.pop(name, None) + + def set_up_module(self, whole_name): + parts = whole_name.split('.') + name = '' + for part in parts: + name += ('.%s' % part) if name else part + module = types.ModuleType(name) + module.__path__ = ['/tmp'] + self._modules.add(name) + sys.modules[name] = module + return sys.modules[whole_name] + + def test_app_labels_1_7(self): + """ + Verifies that app_labels handle Django 1.7+ AppConfigs properly. + https://docs.djangoproject.com/en/1.7/ref/applications/ + """ + if django.VERSION >= (1, 7): + from django.apps import AppConfig + else: + # set up poor man's mock for 1.7/1.8 behaviour. + from collections import namedtuple + + class AppConfig(object): + call_count = 0 + ret_map = { + 'example1': 'example1', + 'example2.apps.Example2AppConfig': 'example2_app', + } + + @classmethod + def create(cls, app): + return namedtuple('AppConfig', ['label'])( + cls.ret_map[app] + ) + dj_apps = self.set_up_module('django.apps') + dj_apps.AppConfig = AppConfig + + reload(utils) + + self.set_up_module('example1') + apps = self.set_up_module('example2.apps') + + # set up AppConfig on the `test_app.apps` module + class Example2AppConfig(AppConfig): + name = 'example2' + label = 'example2_app' # with different name + path = '/tmp' # for whatever reason path is required + + apps.Example2AppConfig = Example2AppConfig + + self.assertEqual( + utils.app_labels([ + 'example1', + 'example2.apps.Example2AppConfig' + ]), + ['example1', 'example2_app'], + ) + + def test_app_labels_pre_1_7(self): + if django.VERSION >= (1, 7): + # set AppConfig to None and reload utils module + from django import apps + AppConfig = apps.AppConfig + apps.AppConfig = None + + reload(utils) + + self.assertEqual( + utils.app_labels( + ['example1', 'example2.apps.Example2AppConfig'] + ), + ['example1', 'Example2AppConfig'] + ) + + if django.VERSION >= (1, 7): + # restore AppConfig + apps.AppConfig = AppConfig + reload(utils) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 5d8961c..ba57808 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -103,37 +103,13 @@ def schema_exists(schema_name): return exists + def app_labels(apps_list): """ - Returns a list of app labels of the given apps_list + Returns a list of app labels of the given apps_list, now properly handles + new Django 1.7+ application registry. - - AppConfig handling test: - >>> import sys - >>> import types - - Wrapping test in a function so we can return from it on Django <1.7 - >>> def run_test(): - ... label = 'test_app1' - ... - ... if AppConfig is None: - ... assert app_labels(['test_apps.' + label]) == [label] - ... return - ... - ... name = 'test_app' - ... sys.modules[name] = types.ModuleType(name) - ... - ... name = 'test_app.apps' - ... sys.modules[name] = types.ModuleType(name) - ... # noinspection PyPep8Naming - ... sys.modules[name].TestAppConfig = type( - ... 'TestAppConfig', - ... (AppConfig,), - ... dict(name='test_app', label=label, path='/tmp') - ... ) - ... assert app_labels(['test_app.apps.TestAppConfig']) == [label] - - >>> run_test() + https://docs.djangoproject.com/en/1.8/ref/applications/#django.apps.AppConfig.label """ if AppConfig is None: return [app.split('.')[-1] for app in apps_list] From 9fe1d657f341886123e64def261e45d278c690a1 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jan 2016 17:01:43 +1100 Subject: [PATCH 003/157] Add tox.ini --- tox.ini | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tox.ini diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..bce61cd --- /dev/null +++ b/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = py27-dj{17,18,19} + +[testenv] +basepython = + py27: python2.7 +usedevelop = True + +deps = + dj17: Django~=1.7.0 + dj18: Django~=1.8.0 + dj19: Django~=1.9.0 + +changedir = dts_test_project + +passenv = PG_USER PG_PASSWORD + +commands = + {envpython} manage.py test tenant_schemas.tests --noinput -v 2 From 1743b4867b883bfd499ae5a0024940ca7ec43b47 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jan 2016 17:03:18 +1100 Subject: [PATCH 004/157] Add LOGGING configuration to test project --- dts_test_project/dts_test_project/settings.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 5bfaec7..44fab54 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -122,3 +122,34 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.6/howto/static-files/ STATIC_URL = '/static/' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + }, + }, + 'formatters': { + 'simple': { + 'format': '%(levelname)-7s %(asctime)s %(message)s', + }, + }, + 'handlers': { + 'null': { + 'class': 'logging.NullHandler', + }, + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'loggers': { + '': { + 'handlers': ['null'], + 'level': 'DEBUG', + 'propagate': True, + }, + }, +} From 57d53c955dbafefb8a6c48b26498724b5b731011 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jan 2016 17:06:57 +1100 Subject: [PATCH 005/157] Add logging in utils.django_in_test_mode function. --- tenant_schemas/utils.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 11a7ac9..eb6b641 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -1,6 +1,9 @@ +import logging from contextlib import contextmanager + from django.conf import settings from django.db import connection + try: from django.apps import apps get_model = apps.get_model @@ -8,6 +11,8 @@ except ImportError: from django.db.models.loading import get_model from django.core import mail +logger = logging.getLogger(__name__) + @contextmanager def schema_context(schema_name): @@ -82,7 +87,9 @@ def django_is_in_test_mode(): I know this is very ugly! I'm looking for more elegant solutions. See: http://stackoverflow.com/questions/6957016/detect-django-testing-mode """ - return hasattr(mail, 'outbox') + in_test_mode = hasattr(mail, 'outbox') + logger.debug('TEST_MODE=%s', in_test_mode) + return in_test_mode def schema_exists(schema_name): From 21048e333307ea7b0ccce52bda8d2ab04f5ede1e Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jan 2016 17:42:01 +1100 Subject: [PATCH 006/157] Update TenantCommandTest.test_command to run under Django 1.8 and 1.9. --- tenant_schemas/tests/test_tenants.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 8ea7c82..8ac0645 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -217,8 +217,12 @@ class TenantCommandTest(BaseTestCase): Tenant(domain_url='localhost', schema_name='public').save(verbosity=BaseTestCase.get_verbosity()) out = StringIO() - call_command('tenant_command', 'dumpdata', 'tenant_schemas', natural_foreign=True, - schema_name=get_public_schema_name(), stdout=out) + if django.VERSION >= (1, 8, 0): + call_command('tenant_command', args=('dumpdata', 'tenant_schemas'), natural_foreign=True, + schema_name=get_public_schema_name(), stdout=out) + else: + call_command('tenant_command', 'dumpdata', 'tenant_schemas', natural_foreign=True, + schema_name=get_public_schema_name(), stdout=out) self.assertItemsEqual( json.loads('[{"fields": {"domain_url": "localhost", "schema_name": "public"}, ' '"model": "tenant_schemas.tenant", "pk": 1}]'), From 920a884a44761aa96174ba7c1a2d9e6f30177b28 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jan 2016 17:50:22 +1100 Subject: [PATCH 007/157] Update dts_test_project.settings to work with codeship CI https://codeship.com/documentation/databases/postgresql/#django --- dts_test_project/dts_test_project/settings.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 44fab54..e428ba5 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -75,10 +75,9 @@ DATABASES = { 'default': { 'ENGINE': 'tenant_schemas.postgresql_backend', 'NAME': 'dts_test_project', - 'USER': 'postgres', - 'PASSWORD': 'root', - 'HOST': 'localhost', - 'PORT': '', + 'USER': os.environ.get('PG_USER', 'postgres'), + 'PASSWORD': os.environ.get('PG_PASSWORD', 'root'), + 'HOST': '127.0.0.1', } } From 02517f9a4d9ec629676a99949fc5e064cd413b64 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jan 2016 17:51:15 +1100 Subject: [PATCH 008/157] Set migration verbosity so as to not spam the output. --- tenant_schemas/tests/testcases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 29280d5..2d4f828 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -39,7 +39,7 @@ class BaseTestCase(TestCase): for s in reversed(inspect.stack()): options = s[0].f_locals.get('options') if isinstance(options, dict): - return int(options['verbosity']) + return int(options['verbosity']) - 2 return 1 @classmethod From d54c947f70f0d65760aacf9ac218c0e39421ec25 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 21 Jan 2016 07:28:44 +1100 Subject: [PATCH 009/157] Documentation update. --- docs/involved.rst | 2 +- docs/test.rst | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/involved.rst b/docs/involved.rst index 84e7755..558659a 100644 --- a/docs/involved.rst +++ b/docs/involved.rst @@ -3,4 +3,4 @@ Get Involved! ============= Suggestions, bugs, ideas, patches, questions -------------------------------------------- -Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub `. :) +Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub `_. :) diff --git a/docs/test.rst b/docs/test.rst index b9b2da6..1834a68 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -9,6 +9,12 @@ If you're using South, don't forget to set ``SOUTH_TESTS_MIGRATE = False``. Run ./manage.py test tenant_schemas.tests +To run the test suite outsite of your application you can use tox_ to test all supported Django versions. + +.. code-block:: bash + + tox + Updating your app's tests to work with tenant-schemas ----------------------------------------------------- Because django will not create tenants for you during your tests, we have packed some custom test cases and other utilities. If you want a test to happen at any of the tenant's domain, you can use the test case ``TenantTestCase``. It will automatically create a tenant for you, set the connection's schema to tenant's schema and make it available at ``self.tenant``. We have also included a ``TenantRequestFactory`` and a ``TenantClient`` so that your requests will all take place at the tenant's domain automatically. Here's an example @@ -25,3 +31,5 @@ Because django will not create tenants for you during your tests, we have packed def test_user_profile_view(self): response = self.c.get(reverse('user_profile')) self.assertEqual(response.status_code, 200) + +.. _tox: https://tox.readthedocs.org/ From 538fdf48f1ce4ac5aef516196192bdbbc199adea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Poul?= Date: Sat, 20 Feb 2016 21:17:39 +0100 Subject: [PATCH 010/157] Raising an exception when a schema is missing, added an option to migrate_schemas to ignore this error --- docs/use.rst | 6 ++++++ tenant_schemas/management/commands/__init__.py | 5 +++++ .../management/commands/migrate_schemas.py | 12 +++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/use.rst b/docs/use.rst index cc2b2d1..235ceb4 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -87,6 +87,12 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H ./manage.py migrate_schemas --list +``migrate_schemas`` raises an exception when an tenant schema is missing. You can ignore these exceptions with the ``--ignore-missing-schemas`` option. + +.. code-block:: bash + + ./manage.py migrate_schemas --ignore-missing-schemas + sync_schemas ~~~~~~~~~~~~ diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 4542d03..d6bca36 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -175,11 +175,16 @@ class SyncCommon(BaseCommand): help=('Database state will be brought to the state after that ' 'migration. Use the name "zero" to unapply all migrations.')) parser.add_argument("-s", "--schema", dest="schema_name") + parser.add_argument("--ignore-missing-schemas", dest="ignore_missing_schemas", + action="store_true", default=False, + help=('Doesn\'t raise an exception when a schema is ' + 'missing in the database.')) def handle(self, *args, **options): self.sync_tenant = options.get('tenant') self.sync_public = options.get('shared') self.schema_name = options.get('schema_name') + self.ignore_missing_schemas = options.get("ignore_missing_schemas") self.installed_apps = settings.INSTALLED_APPS self.args = args self.options = options diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 3178600..ccf79e5 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -46,13 +46,23 @@ class MigrateSchemasCommand(SyncCommon): else: self.run_migrations(self.schema_name, settings.TENANT_APPS) else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) + all_tenants = get_tenant_model().objects.exclude(schema_name=self.PUBLIC_SCHEMA_NAME) for tenant in all_tenants: self.run_migrations(tenant.schema_name, settings.TENANT_APPS) def run_migrations(self, schema_name, included_apps): if int(self.options.get('verbosity', 1)) >= 1: self._notice("=== Running migrate for schema %s" % schema_name) + + if not schema_exists(schema_name): + if not self.ignore_missing_schemas: + raise RuntimeError('Schema "{}" does not exist'.format( + schema_name)) + else: + if int(self.options.get('verbosity', 1)) >= 1: + self._notice("=== Schema %s doesn't exist, ignoring" % schema_name) + return + connection.set_schema(schema_name) command = MigrateCommand() command.execute(*self.args, **self.options) From c8921aa571074819201bb86c78b6d3617d72b7fd Mon Sep 17 00:00:00 2001 From: Jacob Haslehurst Date: Fri, 18 Mar 2016 15:06:56 +1100 Subject: [PATCH 011/157] fix `reverse` for Django 1.9 `reverse` no longer accepts the `prefix` argument https://docs.djangoproject.com/en/1.9/ref/urlresolvers/#reverse --- tenant_schemas/urlresolvers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index 15c0598..810b58b 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -5,9 +5,8 @@ from django.utils.functional import lazy from tenant_schemas.utils import clean_tenant_url -def reverse(viewname, urlconf=None, args=None, kwargs=None, prefix=None, - current_app=None): - url = reverse_default(viewname, urlconf, args, kwargs, prefix, current_app) +def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None): + url = reverse_default(viewname, urlconf, args, kwargs, current_app) return clean_tenant_url(url) reverse_lazy = lazy(reverse, str) From 2e58510b558eead203572da20e0b73f865aae3fd Mon Sep 17 00:00:00 2001 From: Jacob Haslehurst Date: Mon, 21 Mar 2016 11:42:57 +1100 Subject: [PATCH 012/157] explicitly pass kwargs to django reverse --- tenant_schemas/urlresolvers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index 810b58b..36f601a 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -6,7 +6,13 @@ from tenant_schemas.utils import clean_tenant_url def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None): - url = reverse_default(viewname, urlconf, args, kwargs, current_app) + url = reverse_default( + viewname=viewname, + urlconf=urlconf, + args=args, + kwargs=kwargs, + current_app=current_app + ) return clean_tenant_url(url) reverse_lazy = lazy(reverse, str) From 0be38470e5b21d165d3110b27517cbd0a21bf9d8 Mon Sep 17 00:00:00 2001 From: Jacob Haslehurst Date: Wed, 23 Mar 2016 12:13:05 +1100 Subject: [PATCH 013/157] re-add `prefix` argument to `reverse` --- tenant_schemas/urlresolvers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index 36f601a..ba17650 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -5,7 +5,8 @@ from django.utils.functional import lazy from tenant_schemas.utils import clean_tenant_url -def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None): +def reverse(viewname, urlconf=None, args=None, kwargs=None, prefix=None, + current_app=None): url = reverse_default( viewname=viewname, urlconf=urlconf, From f8f96b169fe03fb667b2b65b82f84e1b26353e32 Mon Sep 17 00:00:00 2001 From: Jacob Haslehurst Date: Wed, 23 Mar 2016 12:13:30 +1100 Subject: [PATCH 014/157] remove unused imports --- tenant_schemas/urlresolvers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index ba17650..00e36f6 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -1,5 +1,3 @@ -import warnings -from django.conf import settings from django.core.urlresolvers import reverse as reverse_default from django.utils.functional import lazy from tenant_schemas.utils import clean_tenant_url From af631d5fe6d7ca034a0cab431addeccb2e13ebc3 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 28 Mar 2016 12:09:38 +0200 Subject: [PATCH 015/157] Fixed the RemovedInDjango110Warning related to the url patterns from the tutorial. Thanks @mikicz. Fixes #320 --- .../tenant_tutorial/tenant_tutorial/urls_public.py | 13 +++++++++---- .../tenant_tutorial/tenant_tutorial/urls_tenants.py | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/examples/tenant_tutorial/tenant_tutorial/urls_public.py b/examples/tenant_tutorial/tenant_tutorial/urls_public.py index 5a0b6b2..aac2f04 100644 --- a/examples/tenant_tutorial/tenant_tutorial/urls_public.py +++ b/examples/tenant_tutorial/tenant_tutorial/urls_public.py @@ -1,6 +1,11 @@ -from django.conf.urls import patterns, url +import django +from django.conf.urls import url from tenant_tutorial.views import HomeView -urlpatterns = patterns('', - url(r'^$', HomeView.as_view()), - ) + +urlpatterns = [ + url(r'^$', HomeView.as_view()), +] + +if django.VERSION < (1, 9, 0): + urlpatterns = django.conf.urls.patterns('', *urlpatterns) diff --git a/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py b/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py index a113b93..a82100b 100644 --- a/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py +++ b/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py @@ -1,6 +1,11 @@ -from django.conf.urls import patterns, url +import django +from django.conf.urls import url from customers.views import TenantView -urlpatterns = patterns('', - url(r'^$', TenantView.as_view()), - ) + +urlpatterns = [ + url(r'^$', TenantView.as_view()), +] + +if django.VERSION < (1, 9, 0): + urlpatterns = django.conf.urls.patterns('', *urlpatterns) From 3d0e7463f32e396941f5807238cf4b2b357210c8 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 28 Mar 2016 16:17:23 +0200 Subject: [PATCH 016/157] removed logging from utils --- tenant_schemas/utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index eb6b641..eb5169a 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -1,4 +1,3 @@ -import logging from contextlib import contextmanager from django.conf import settings @@ -11,7 +10,6 @@ except ImportError: from django.db.models.loading import get_model from django.core import mail -logger = logging.getLogger(__name__) @contextmanager @@ -87,9 +85,7 @@ def django_is_in_test_mode(): I know this is very ugly! I'm looking for more elegant solutions. See: http://stackoverflow.com/questions/6957016/detect-django-testing-mode """ - in_test_mode = hasattr(mail, 'outbox') - logger.debug('TEST_MODE=%s', in_test_mode) - return in_test_mode + return hasattr(mail, 'outbox') def schema_exists(schema_name): From 4d95f7a1195bac0b527648173318189980fb6fb4 Mon Sep 17 00:00:00 2001 From: Martin Samsula Date: Thu, 31 Mar 2016 11:09:42 +0200 Subject: [PATCH 017/157] router multidb fix --- tenant_schemas/routers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tenant_schemas/routers.py b/tenant_schemas/routers.py index ec484e2..ed5c784 100644 --- a/tenant_schemas/routers.py +++ b/tenant_schemas/routers.py @@ -14,6 +14,9 @@ class TenantSyncRouter(object): from django.db import connection from tenant_schemas.utils import get_public_schema_name, app_labels + if settings.DATABASES[db]['ENGINE'] != 'tenant_schemas.postgresql_backend': + return None + if isinstance(app_label, ModelBase): # In django <1.7 the `app_label` parameter is actually `model` app_label = app_label._meta.app_label From bb536708c418e3f700d529af982602664d5b9c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Poul?= Date: Thu, 7 Apr 2016 17:23:41 +0200 Subject: [PATCH 018/157] Removed the --ignore-missing-schemas option, raising a different exception --- docs/use.rst | 6 +----- tenant_schemas/management/commands/__init__.py | 5 ----- .../management/commands/migrate_schemas.py | 12 ++++-------- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/docs/use.rst b/docs/use.rst index 235ceb4..bbbfd38 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -87,11 +87,7 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H ./manage.py migrate_schemas --list -``migrate_schemas`` raises an exception when an tenant schema is missing. You can ignore these exceptions with the ``--ignore-missing-schemas`` option. - -.. code-block:: bash - - ./manage.py migrate_schemas --ignore-missing-schemas +``migrate_schemas`` raises an exception when an tenant schema is missing. sync_schemas ~~~~~~~~~~~~ diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index d6bca36..4542d03 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -175,16 +175,11 @@ class SyncCommon(BaseCommand): help=('Database state will be brought to the state after that ' 'migration. Use the name "zero" to unapply all migrations.')) parser.add_argument("-s", "--schema", dest="schema_name") - parser.add_argument("--ignore-missing-schemas", dest="ignore_missing_schemas", - action="store_true", default=False, - help=('Doesn\'t raise an exception when a schema is ' - 'missing in the database.')) def handle(self, *args, **options): self.sync_tenant = options.get('tenant') self.sync_public = options.get('shared') self.schema_name = options.get('schema_name') - self.ignore_missing_schemas = options.get("ignore_missing_schemas") self.installed_apps = settings.INSTALLED_APPS self.args = args self.options = options diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index ccf79e5..0ee4b30 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -6,6 +6,7 @@ if django.VERSION >= (1, 7, 0): from django.db.migrations.recorder import MigrationRecorder from django.db import connection from django.conf import settings +from django.db.migrations.exceptions import MigrationSchemaMissing from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists from tenant_schemas.management.commands import SyncCommon @@ -41,7 +42,7 @@ class MigrateSchemasCommand(SyncCommon): if self.sync_tenant: if self.schema_name and self.schema_name != self.PUBLIC_SCHEMA_NAME: if not schema_exists(self.schema_name): - raise RuntimeError('Schema "{}" does not exist'.format( + raise MigrationSchemaMissing('Schema "{}" does not exist'.format( self.schema_name)) else: self.run_migrations(self.schema_name, settings.TENANT_APPS) @@ -55,13 +56,8 @@ class MigrateSchemasCommand(SyncCommon): self._notice("=== Running migrate for schema %s" % schema_name) if not schema_exists(schema_name): - if not self.ignore_missing_schemas: - raise RuntimeError('Schema "{}" does not exist'.format( - schema_name)) - else: - if int(self.options.get('verbosity', 1)) >= 1: - self._notice("=== Schema %s doesn't exist, ignoring" % schema_name) - return + raise MigrationSchemaMissing('Schema "{}" does not exist'.format( + schema_name)) connection.set_schema(schema_name) command = MigrateCommand() From 5b9014303503a4423e32f9e18622a337a5ad4a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Poul?= Date: Fri, 15 Apr 2016 12:54:53 +0200 Subject: [PATCH 019/157] Undid the change in getting the public schema name --- tenant_schemas/management/commands/migrate_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 0ee4b30..7e485b5 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -47,7 +47,7 @@ class MigrateSchemasCommand(SyncCommon): else: self.run_migrations(self.schema_name, settings.TENANT_APPS) else: - all_tenants = get_tenant_model().objects.exclude(schema_name=self.PUBLIC_SCHEMA_NAME) + all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) for tenant in all_tenants: self.run_migrations(tenant.schema_name, settings.TENANT_APPS) From 0be0b2d227b4210f0fa5f95be5cd417beae2eaed Mon Sep 17 00:00:00 2001 From: John Louis Del Rosario Date: Fri, 15 Apr 2016 19:18:50 +0800 Subject: [PATCH 020/157] Only send post_sync_signal on successful schema creation This avoids masking any errors that might happen in signal handlers. --- tenant_schemas/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 1781051..672fb04 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -50,12 +50,13 @@ class TenantMixin(models.Model): if is_new and self.auto_create_schema: try: self.create_schema(check_if_exists=True, verbosity=verbosity) - post_schema_sync.send(sender=TenantMixin, tenant=self) except: # We failed creating the tenant, delete what we created and # re-raise the exception self.delete(force_drop=True) raise + else: + post_schema_sync.send(sender=TenantMixin, tenant=self) def delete(self, force_drop=False, *args, **kwargs): """ From 388f4b6e83a1703570198856487bf4f9ea7584d6 Mon Sep 17 00:00:00 2001 From: Marek Skrajnowski Date: Mon, 18 Apr 2016 13:36:37 +0200 Subject: [PATCH 021/157] Fix: missing schema-aware introspection methods --- .../migrations/0002_test_drop_unique.py | 32 ++ .../postgresql_backend/introspection.py | 330 +++++++++++++++++- 2 files changed, 345 insertions(+), 17 deletions(-) create mode 100644 dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py diff --git a/dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py b/dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py new file mode 100644 index 0000000..d8d5f12 --- /dev/null +++ b/dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dts_test_app', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='DummyModel', + name='unique_value', + field=models.IntegerField(blank=True, null=True, unique=True), + ), + + migrations.AlterField( + model_name='DummyModel', + name='unique_value', + field=models.IntegerField(blank=True, null=True), + ), + + migrations.RemoveField( + model_name='DummyModel', + name='unique_value', + ), + ] diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 570654d..b7af082 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -1,22 +1,318 @@ import django -from django.db.backends.postgresql_psycopg2.introspection import DatabaseIntrospection -if django.VERSION >= (1, 8, 0): - from django.db.backends.base.introspection import TableInfo +from django.db.backends.postgresql_psycopg2.introspection import ( + DatabaseIntrospection, + FieldInfo, +) +from django.utils.encoding import force_text + + +def _build_table_info(row): + if django.VERSION < (1, 8, 0): + return row[0] + else: + from django.db.backends.base.introspection import TableInfo + return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) + + +def _build_field_info(col, field_map): + col_name = force_text(col[0]) + + info_args = [col_name] + info_args.extend(col[1:6]) + + # is nullable + info_args.append(field_map[col_name][0] == 'YES') + + # django 1.8 added default value to FieldInfo + if django.VERSION >= (1, 8, 0): + # default value + info_args.append(field_map[col_name][1]) + + return FieldInfo(*info_args) class DatabaseSchemaIntrospection(DatabaseIntrospection): + _get_table_list_query = """ + SELECT c.relname, c.relkind + FROM pg_catalog.pg_class c + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'v', '') + AND n.nspname = %(schema)s + AND pg_catalog.pg_table_is_visible(c.oid) + """ + + _get_table_description_query = """ + SELECT column_name, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = %(table)s + AND table_schema = %(schema)s + """ + + _get_relations_query = """ + SELECT c2.relname, a1.attname, a2.attname, con.conkey, con.confkey + FROM pg_catalog.pg_constraint con + LEFT JOIN pg_catalog.pg_class c1 ON con.conrelid = c1.oid + LEFT JOIN pg_catalog.pg_class c2 ON con.confrelid = c2.oid + LEFT JOIN pg_catalog.pg_attribute a1 ON c1.oid = a1.attrelid + AND a1.attnum = con.conkey[1] + LEFT JOIN pg_catalog.pg_attribute a2 ON c2.oid = a2.attrelid + AND a2.attnum = con.confkey[1] + LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = c1.connamespace + WHERE c1.relname = %(table)s + AND n1.nspname = %(schema)s + AND con.contype = 'f' + """ + + _get_key_columns_query = """ + SELECT kcu.column_name, ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column + FROM information_schema.constraint_column_usage ccu + LEFT JOIN information_schema.key_column_usage kcu + ON ccu.constraint_catalog = kcu.constraint_catalog + AND ccu.constraint_schema = kcu.constraint_schema + AND ccu.constraint_name = kcu.constraint_name + LEFT JOIN information_schema.table_constraints tc + ON ccu.constraint_catalog = tc.constraint_catalog + AND ccu.constraint_schema = tc.constraint_schema + AND ccu.constraint_name = tc.constraint_name + WHERE kcu.table_name = %(table)s + AND kcu.table_schame = %(schema)s + AND tc.constraint_type = 'FOREIGN KEY' + """ + + _get_indexes_query = """ + SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary + FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, + pg_catalog.pg_index idx, pg_catalog.pg_attribute attr, + pg_catalog.pg_namespace n + WHERE c.oid = idx.indrelid + AND idx.indexrelid = c2.oid + AND attr.attrelid = c.oid + AND attr.attnum = idx.indkey[0] + AND c.relnamespace = n.oid + AND c.relname = %(table)s + AND n.nspname = %(schema)s + """ + + _get_constaints_query = """ + SELECT + kc.constraint_name, + kc.column_name, + c.constraint_type, + array(SELECT table_name::text || '.' || column_name::text + FROM information_schema.constraint_column_usage + WHERE constraint_name = kc.constraint_name) + FROM information_schema.key_column_usage AS kc + JOIN information_schema.table_constraints AS c ON + kc.table_schema = c.table_schema AND + kc.table_name = c.table_name AND + kc.constraint_name = c.constraint_name + WHERE + kc.table_schema = %(schema)s AND + kc.table_name = %(table)s + ORDER BY kc.ordinal_position ASC + """ + + _get_check_constraints_query = """ + SELECT kc.constraint_name, kc.column_name + FROM information_schema.constraint_column_usage AS kc + JOIN information_schema.table_constraints AS c ON + kc.table_schema = c.table_schema AND + kc.table_name = c.table_name AND + kc.constraint_name = c.constraint_name + WHERE + c.constraint_type = 'CHECK' AND + kc.table_schema = %(schema)s AND + kc.table_name = %(table)s + """ + + _get_index_constraints_query = """ + SELECT + c2.relname, + ARRAY( + SELECT ( + SELECT attname + FROM pg_catalog.pg_attribute + WHERE attnum = i AND attrelid = c.oid + ) + FROM unnest(idx.indkey) i + ), + idx.indisunique, + idx.indisprimary + FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, + pg_catalog.pg_index idx, pg_catalog.pg_namespace n + WHERE c.oid = idx.indrelid + AND idx.indexrelid = c2.oid + AND n.oid = c.relnamespace + AND c.relname = %(table)s + AND n.nspname = %(schema)s + """ + def get_table_list(self, cursor): - "Returns a list of table names in the current database and schema." - cursor.execute(""" - SELECT c.relname, c.relkind - FROM pg_catalog.pg_class c - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r', 'v', '') - AND n.nspname = '%s' - AND pg_catalog.pg_table_is_visible(c.oid)""" % self.connection.schema_name) - if django.VERSION >= (1, 8, 0): - return [TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) - for row in cursor.fetchall() - if row[0] not in self.ignored_tables] - else: - return [row[0] for row in cursor.fetchall() if row[0] not in self.ignored_tables] + """ + Returns a list of table names in the current database and schema. + """ + + cursor.execute(self._get_table_list_query, { + 'schema': self.connection.schema_name, + }) + + return [ + _build_table_info(row) + for row in cursor.fetchall() + if row[0] not in self.ignored_tables + ] + + def get_table_description(self, cursor, table_name): + """ + Returns a description of the table, with the DB-API + cursor.description interface. + """ + + # As cursor.description does not return reliably the nullable property, + # we have to query the information_schema (#7783) + cursor.execute(self._get_table_description_query, { + 'schema': self.connection.schema_name, + 'table': table_name + }) + + field_map = {line[0]: line[1:] for line in cursor.fetchall()} + + cursor.execute('SELECT * FROM %s.%s LIMIT 1' % ( + self.connection.schema_name, + self.connection.ops.quote_name(table_name), + )) + + return [ + _build_field_info(line, field_map) + for line in cursor.description + ] + + def get_relations(self, cursor, table_name): + """ + Returns a dictionary of + {field_name: (field_name_other_table, other_table)} + representing all relationships to the given table. + + Django 1.7: + Returns a dictionary of + {field_index: (field_index_other_table, other_table)} + representing all relationships to the given table. Indexes are 0-based. + """ + cursor.execute(self._get_relations_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + relations = {} + for row in cursor.fetchall(): + if django.VERSION < (1, 8, 0): + # row[3] and row[4] are single-item lists, + # so grab the single item. + relations[row[3][0] - 1] = (row[4][0] - 1, row[0]) + else: + relations[row[1]] = (row[2], row[0]) + return relations + + def get_key_columns(self, cursor, table_name): + cursor.execute(self._get_key_columns_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + return list(cursor.fetchall()) + + def get_indexes(self, cursor, table_name): + # This query retrieves each index on the given table, including the + # first associated field name + cursor.execute(self._get_indexes_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + indexes = {} + for row in cursor.fetchall(): + # row[1] (idx.indkey) is stored in the DB as an array. + # It comes out as a string of space-separated integers. + # This designates the field indexes (1-based) of the fields + # that have indexes on the table. Here, we skip any indexes + # across multiple fields. + if ' ' in row[1]: + continue + if row[0] not in indexes: + indexes[row[0]] = {'primary_key': False, 'unique': False} + # It's possible to have the unique and PK constraints + # in separate indexes. + if row[3]: + indexes[row[0]]['primary_key'] = True + if row[2]: + indexes[row[0]]['unique'] = True + return indexes + + def get_constraints(self, cursor, table_name): + """ + Retrieves any constraints or keys (unique, pk, fk, check, index) + across one or more columns. + """ + constraints = {} + + # Loop over the key table, collecting things as constraints + # This will get PKs, FKs, and uniques, but not CHECK + cursor.execute(self._get_constaints_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + for constraint, column, kind, used_cols in cursor.fetchall(): + # If we're the first column, make the record + if constraint not in constraints: + constraints[constraint] = { + "columns": [], + "primary_key": kind.lower() == "primary key", + "unique": kind.lower() in ["primary key", "unique"], + "foreign_key": + tuple(used_cols[0].split(".", 1)) + if kind.lower() == "foreign key" + else None, + "check": False, + "index": False, + } + # Record the details + constraints[constraint]['columns'].append(column) + + # Now get CHECK constraint columns + cursor.execute(self._get_check_constraints_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + for constraint, column in cursor.fetchall(): + # If we're the first column, make the record + if constraint not in constraints: + constraints[constraint] = { + "columns": [], + "primary_key": False, + "unique": False, + "foreign_key": None, + "check": True, + "index": False, + } + # Record the details + constraints[constraint]['columns'].append(column) + + # Now get indexes + cursor.execute(self._get_index_constraints_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + for index, columns, unique, primary in cursor.fetchall(): + if index not in constraints: + constraints[index] = { + "columns": list(columns), + "primary_key": primary, + "unique": unique, + "foreign_key": None, + "check": False, + "index": True, + } + + return constraints From cb8862d026750a5543867e246c0e8de09b21c4fa Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 17 May 2016 19:34:48 +1000 Subject: [PATCH 022/157] Remove Django < 1.8 compatibility - resolves template loading in Django 1.8+ (thanks to @tomturner) - removed code branches based on django.VERSION value - updated documentation to remove details of removed functionality Closes #289 --- README.rst | 3 +- docs/install.rst | 39 +------ docs/use.rst | 25 +--- dts_test_project/dts_test_project/settings.py | 18 ++- .../templates/index_public.html | 4 - .../tenant_tutorial/settings.py | 6 +- .../tenant_tutorial/urls_public.py | 4 - .../tenant_tutorial/urls_tenants.py | 7 +- .../tenant_tutorial/tenant_tutorial/views.py | 7 +- setup.py | 5 +- tenant_schemas/__init__.py | 7 +- .../management/commands/__init__.py | 42 +------ .../management/commands/legacy/__init__.py | 3 - .../commands/legacy/migrate_schemas.py | 88 -------------- tenant_schemas/management/commands/migrate.py | 6 +- .../management/commands/migrate_schemas.py | 28 ++--- .../management/commands/sync_schemas.py | 57 ---------- tenant_schemas/management/commands/syncdb.py | 21 ---- tenant_schemas/models.py | 26 +---- .../postgresql_backend/introspection.py | 26 +---- tenant_schemas/template_loaders.py | 107 ++++++++++-------- tenant_schemas/test/cases.py | 21 +--- tenant_schemas/tests/test_tenants.py | 19 ++-- tenant_schemas/tests/test_utils.py | 48 +------- tenant_schemas/tests/testcases.py | 19 +--- tox.ini | 3 +- 26 files changed, 121 insertions(+), 518 deletions(-) delete mode 100644 tenant_schemas/management/commands/legacy/__init__.py delete mode 100644 tenant_schemas/management/commands/legacy/migrate_schemas.py delete mode 100644 tenant_schemas/management/commands/sync_schemas.py delete mode 100644 tenant_schemas/management/commands/syncdb.py diff --git a/README.rst b/README.rst index c885f2c..b4d7edf 100644 --- a/README.rst +++ b/README.rst @@ -160,8 +160,7 @@ created ``Client`` inside an app named ``customers``, your TENANT_MODEL = "customers.Client" # app.Model -Now run ``migrate_schemas`` (``sync_schemas`` if you're on Django 1.6 and older), -this will sync your apps to the ``public`` schema. +Now run ``migrate_schemas`` to sync your apps to the ``public`` schema. :: diff --git a/docs/install.rst b/docs/install.rst index ffe586b..a564bea 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -73,7 +73,6 @@ Once you have defined your model, don't forget to create the migrations for it o .. code-block:: bash - # Django >= 1.7 python manage.py makemigrations customers Configure Tenant and Shared Applications @@ -113,20 +112,16 @@ You also have to set where your tenant model is. TENANT_MODEL = "customers.Client" # app.Model -Now run ``migrate_schemas --shared`` (``sync_schemas --shared`` if you're on Django 1.6 or older), this will create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command. +Now run ``migrate_schemas --shared`` to create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command. .. code-block:: bash - # Django >= 1.7 python manage.py migrate_schemas --shared - # Django < 1.7 - python manage.py sync_schemas --shared - .. warning:: - Never use ``migrate`` or ``syncdb`` as it would sync *all* your apps to ``public``! - + Never use ``migrate`` as it would sync *all* your apps to ``public``! + Lastly, you need to create a tenant whose schema is ``public`` and it's address is your domain URL. Please see the section on :doc:`use `. You can also specify extra schemas that should be visible to all queries using @@ -145,34 +140,6 @@ globally. available globally. This helps avoid issues caused by hiding the public schema from queries. -South Migrations -================ -If you're on Django 1.6 or older, this app supports `South `_ so if you haven't configured it yet and would like to: - -For Django 1.1 or below - -.. code-block:: python - - SOUTH_DATABASE_ADAPTER = 'south.db.postgresql_psycopg2' - -For Django 1.2 or above - -.. code-block:: python - - SOUTH_DATABASE_ADAPTERS = { - 'default': 'south.db.postgresql_psycopg2', - } - -You can list ``south`` under ``TENANT_APPS`` and ``SHARED_APPS`` if you want. - -We override ``south``'s ``syncdb`` and ``migrate`` command, so you'll need to change your ``INSTALLED_APPS`` to - -.. code-block:: python - - INSTALLED_APPS = SHARED_APPS + TENANT_APPS + ('tenant_schemas',) - -This makes sure ``tenant_schemas`` is the last on the list and therefore always has precedence when running an overridden command. - Optional Settings ================= diff --git a/docs/use.rst b/docs/use.rst index cc2b2d1..09015e1 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -66,12 +66,12 @@ To run only a particular schema, there is an optional argument called ``--schema .. code-block:: bash - ./manage.py sync_schemas --schema=customer1 + ./manage.py migrate_schemas --schema=customer1 migrate_schemas ~~~~~~~~~~~~~~~ -If you're on Django 1.7 or newer, ``migrate_schemas`` is the most important command on this app. The way it works is that it calls Django's ``migrate`` in two different ways. First, it calls ``migrate`` for the ``public`` schema, only syncing the shared apps. Then it runs ``migrate`` for every tenant in the database, this time only syncing the tenant apps. +``migrate_schemas`` is the most important command on this app. The way it works is that it calls Django's ``migrate`` in two different ways. First, it calls ``migrate`` for the ``public`` schema, only syncing the shared apps. Then it runs ``migrate`` for every tenant in the database, this time only syncing the tenant apps. .. warning:: @@ -87,27 +87,6 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H ./manage.py migrate_schemas --list -sync_schemas -~~~~~~~~~~~~ - -If you're on Django 1.6 or older, we also packed ``sync_schemas``. It will also respect the ``SHARED_APPS`` and ``TENANT_APPS`` settings, so if you're syncing the ``public`` schema it will only sync ``SHARED_APPS``. If you're syncing tenants, it will only migrate ``TENANT_APPS``. - -.. warning:: - - You should never directly call ``syncdb``. We perform some magic in order to make ``syncdb`` only sync the appropriate apps. - -The options given to ``sync_schemas`` are passed to every ``syncdb``. So if you use South, you may find this handy - -.. code-block:: bash - - ./manage.py sync_schemas --migrate - -You can also use the option ``--tenant`` to only sync tenant apps or ``--shared`` to only sync shared apps. - -.. code-block:: bash - - ./manage.py sync_schemas --shared # will only sync the public schema - tenant_command ~~~~~~~~~~~~~~ diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index e428ba5..0be39cf 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -2,10 +2,10 @@ Django settings for dts_test_project project. For more information on this file, see -https://docs.djangoproject.com/en/1.6/topics/settings/ +https://docs.djangoproject.com/en/1.8/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.6/ref/settings/ +https://docs.djangoproject.com/en/1.8/ref/settings/ """ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -14,7 +14,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = 'cl1)b#c&xmm36z3e(quna-vb@ab#&gpjtdjtpyzh!qn%bc^xxn' @@ -48,11 +48,7 @@ TENANT_MODEL = "customers.Client" # app.Model TEST_RUNNER = 'django.test.runner.DiscoverRunner' -import django -if django.VERSION >= (1, 7, 0): - INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS)) -else: - INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',) +INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS)) MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', @@ -69,7 +65,7 @@ WSGI_APPLICATION = 'dts_test_project.wsgi.application' # Database -# https://docs.djangoproject.com/en/1.6/ref/settings/#databases +# https://docs.djangoproject.com/en/1.8/ref/settings/#databases DATABASES = { 'default': { @@ -104,7 +100,7 @@ TEMPLATE_CONTEXT_PROCESSORS = ( ) # Internationalization -# https://docs.djangoproject.com/en/1.6/topics/i18n/ +# https://docs.djangoproject.com/en/1.8/topics/i18n/ LANGUAGE_CODE = 'en-us' @@ -118,7 +114,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.6/howto/static-files/ +# https://docs.djangoproject.com/en/1.8/howto/static-files/ STATIC_URL = '/static/' diff --git a/examples/tenant_tutorial/templates/index_public.html b/examples/tenant_tutorial/templates/index_public.html index 3b4a0aa..93d5f45 100644 --- a/examples/tenant_tutorial/templates/index_public.html +++ b/examples/tenant_tutorial/templates/index_public.html @@ -18,11 +18,7 @@

Just run the command below on your shell to sync SHARED_APPS. Make sure your environment has Django and django-tenant-schemas available.

-{% if DJANGO17 %}
$ python manage.py migrate_schemas --shared
-{% else %} -
$ python manage.py sync_schemas --shared
-{% endif %}

When you're done refresh this page.

{% elif no_public_tenant %}

Second Step: Create a public tenant

diff --git a/examples/tenant_tutorial/tenant_tutorial/settings.py b/examples/tenant_tutorial/tenant_tutorial/settings.py index f7ab9da..c0d8b5a 100644 --- a/examples/tenant_tutorial/tenant_tutorial/settings.py +++ b/examples/tenant_tutorial/tenant_tutorial/settings.py @@ -143,11 +143,7 @@ TENANT_APPS = ( TENANT_MODEL = "customers.Client" # app.Model -import django -if django.VERSION >= (1, 7, 0): - INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS)) -else: - INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',) +INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS)) SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' diff --git a/examples/tenant_tutorial/tenant_tutorial/urls_public.py b/examples/tenant_tutorial/tenant_tutorial/urls_public.py index aac2f04..d359891 100644 --- a/examples/tenant_tutorial/tenant_tutorial/urls_public.py +++ b/examples/tenant_tutorial/tenant_tutorial/urls_public.py @@ -1,4 +1,3 @@ -import django from django.conf.urls import url from tenant_tutorial.views import HomeView @@ -6,6 +5,3 @@ from tenant_tutorial.views import HomeView urlpatterns = [ url(r'^$', HomeView.as_view()), ] - -if django.VERSION < (1, 9, 0): - urlpatterns = django.conf.urls.patterns('', *urlpatterns) diff --git a/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py b/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py index a82100b..f37dff5 100644 --- a/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py +++ b/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py @@ -1,11 +1,6 @@ -import django -from django.conf.urls import url from customers.views import TenantView - +from django.conf.urls import url urlpatterns = [ url(r'^$', TenantView.as_view()), ] - -if django.VERSION < (1, 9, 0): - urlpatterns = django.conf.urls.patterns('', *urlpatterns) diff --git a/examples/tenant_tutorial/tenant_tutorial/views.py b/examples/tenant_tutorial/tenant_tutorial/views.py index 8fe6826..451f317 100644 --- a/examples/tenant_tutorial/tenant_tutorial/views.py +++ b/examples/tenant_tutorial/tenant_tutorial/views.py @@ -1,9 +1,9 @@ -import django +from customers.models import Client from django.conf import settings from django.db import utils from django.views.generic import TemplateView + from tenant_schemas.utils import remove_www -from customers.models import Client class HomeView(TemplateView): @@ -14,9 +14,6 @@ class HomeView(TemplateView): hostname_without_port = remove_www(self.request.get_host().split(':')[0]) - if django.VERSION >= (1, 7, 0): - context['DJANGO17'] = True - try: Client.objects.get(schema_name='public') except utils.DatabaseError: diff --git a/setup.py b/setup.py index 5ac64e2..cbfdaa5 100755 --- a/setup.py +++ b/setup.py @@ -8,8 +8,6 @@ try: except ImportError: from distutils.core import setup -__version__ = "1.4.0" - setup( name='django-tenant-schemas', version=get_git_version(), @@ -20,7 +18,6 @@ setup( 'tenant_schemas.postgresql_backend', 'tenant_schemas.management', 'tenant_schemas.management.commands', - 'tenant_schemas.management.commands.legacy', 'tenant_schemas.templatetags', 'tenant_schemas.test', 'tenant_schemas.tests', @@ -36,7 +33,7 @@ setup( 'Programming Language :: Python', ], install_requires=[ - 'Django >= 1.6.0', + 'Django >= 1.8.0', 'psycopg2', ], zip_safe=False, diff --git a/tenant_schemas/__init__.py b/tenant_schemas/__init__.py index 6898c66..c8b5093 100644 --- a/tenant_schemas/__init__.py +++ b/tenant_schemas/__init__.py @@ -1,9 +1,7 @@ -import django -import warnings from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from tenant_schemas.utils import get_public_schema_name, get_tenant_model +from tenant_schemas.utils import get_public_schema_name, get_tenant_model recommended_config = """ Warning: You should put 'tenant_schemas' at the end of INSTALLED_APPS: @@ -23,9 +21,6 @@ if not settings.TENANT_APPS: if not hasattr(settings, 'TENANT_MODEL'): raise ImproperlyConfigured('TENANT_MODEL setting not set') -if django.VERSION < (1, 7, 0) and settings.INSTALLED_APPS[-1] != 'tenant_schemas': - warnings.warn(recommended_config, SyntaxWarning) - if 'tenant_schemas.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS: raise ImproperlyConfigured("DATABASE_ROUTERS setting must contain " "'tenant_schemas.routers.TenantSyncRouter'.") diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 4542d03..799b76a 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -1,9 +1,7 @@ -import django -from optparse import make_option from django.conf import settings from django.core.management import call_command, get_commands, load_command_class -from django.core.management.base import BaseCommand, NoArgsCommand, CommandError -from django.db import connection, DEFAULT_DB_ALIAS +from django.core.management.base import BaseCommand, CommandError +from django.db import connection try: from django.utils.six.moves import input @@ -32,16 +30,6 @@ class BaseTenantCommand(BaseCommand): else: cmdclass = load_command_class(app_name, obj.COMMAND_NAME) - if django.VERSION < (1, 8, 0): - # inherit the options from the original command - obj.option_list = cmdclass.option_list - obj.option_list += ( - make_option("-s", "--schema", dest="schema_name"), - ) - obj.option_list += ( - make_option("-p", "--skip-public", dest="skip_public", action="store_true", default=False), - ) - # prepend the command's original help with the info about schemata iteration obj.help = "Calls %s for all registered schemata. You can use regular %s options. " \ "Original help for %s: %s" % (obj.COMMAND_NAME, obj.COMMAND_NAME, obj.COMMAND_NAME, @@ -83,12 +71,6 @@ class BaseTenantCommand(BaseCommand): class InteractiveTenantOption(object): - def __init__(self, *args, **kwargs): - super(InteractiveTenantOption, self).__init__(*args, **kwargs) - if django.VERSION < (1, 8, 0): - self.option_list += ( - make_option("-s", "--schema", dest="schema_name", help="specify tenant schema"), - ) def add_arguments(self, parser): parser.add_argument("-s", "--schema", dest="schema_name", help="specify tenant schema") @@ -143,28 +125,8 @@ class TenantWrappedCommand(InteractiveTenantOption, BaseCommand): class SyncCommon(BaseCommand): - if django.VERSION < (1, 8, 0): - option_list = ( - make_option('--tenant', action='store_true', dest='tenant', default=False, - help='Tells Django to populate only tenant applications.'), - make_option('--shared', action='store_true', dest='shared', default=False, - help='Tells Django to populate only shared applications.'), - make_option('--app_label', action='store', dest='app_label', nargs='?', - help='App label of an application to synchronize the state.'), - make_option('--migration_name', action='store', dest='migration_name', nargs='?', - help=('Database state will be brought to the state after that ' - 'migration. Use the name "zero" to unapply all migrations.')), - make_option("-s", "--schema", dest="schema_name"), - ) - - def __init__(self, stdout=None, stderr=None, no_color=False): - if django.VERSION >= (1, 8, 0): - super(SyncCommon, self).__init__(stdout, stderr, no_color) - else: - super(SyncCommon, self).__init__() def add_arguments(self, parser): - # for django 1.8 and above parser.add_argument('--tenant', action='store_true', dest='tenant', default=False, help='Tells Django to populate only tenant applications.') parser.add_argument('--shared', action='store_true', dest='shared', default=False, diff --git a/tenant_schemas/management/commands/legacy/__init__.py b/tenant_schemas/management/commands/legacy/__init__.py deleted file mode 100644 index 40eb513..0000000 --- a/tenant_schemas/management/commands/legacy/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Commands for Django <=1.6 (before south was integrated to Django). -""" diff --git a/tenant_schemas/management/commands/legacy/migrate_schemas.py b/tenant_schemas/management/commands/legacy/migrate_schemas.py deleted file mode 100644 index 5af69c5..0000000 --- a/tenant_schemas/management/commands/legacy/migrate_schemas.py +++ /dev/null @@ -1,88 +0,0 @@ -from django.conf import settings -from django.db import connection -from south import migration -from south.migration.base import Migrations -from south.management.commands.migrate import Command as MigrateCommand -from tenant_schemas.management.commands import SyncCommon -from tenant_schemas.utils import get_tenant_model, get_public_schema_name - - -class Command(SyncCommon): - help = "Migrate schemas with South" - option_list = MigrateCommand.option_list + SyncCommon.option_list - - def handle(self, *args, **options): - super(Command, self).handle(*args, **options) - - if self.sync_public: - self.migrate_public_apps() - if self.sync_tenant: - self.migrate_tenant_apps(self.schema_name) - - def _set_managed_apps(self, included_apps, excluded_apps): - """ while sync_schemas works by setting which apps are managed, on south we set which apps should be ignored """ - ignored_apps = [] - if excluded_apps: - for item in excluded_apps: - if item not in included_apps: - ignored_apps.append(item) - - for app in ignored_apps: - app_label = app.split('.')[-1] - settings.SOUTH_MIGRATION_MODULES[app_label] = 'ignore' - - self._clear_south_cache() - - def _save_south_settings(self): - self._old_south_modules = None - if hasattr(settings, "SOUTH_MIGRATION_MODULES") and settings.SOUTH_MIGRATION_MODULES is not None: - self._old_south_modules = settings.SOUTH_MIGRATION_MODULES.copy() - else: - settings.SOUTH_MIGRATION_MODULES = dict() - - def _restore_south_settings(self): - settings.SOUTH_MIGRATION_MODULES = self._old_south_modules - - def _clear_south_cache(self): - for mig in list(migration.all_migrations()): - delattr(mig._application, "migrations") - Migrations._clear_cache() - - def _migrate_schema(self, tenant): - connection.set_tenant(tenant, include_public=True) - MigrateCommand().execute(*self.args, **self.options) - - def migrate_tenant_apps(self, schema_name=None): - self._save_south_settings() - - apps = self.tenant_apps or self.installed_apps - self._set_managed_apps(included_apps=apps, excluded_apps=self.shared_apps) - - if schema_name: - self._notice("=== Running migrate for schema: %s" % schema_name) - connection.set_schema_to_public() - tenant = get_tenant_model().objects.get(schema_name=schema_name) - self._migrate_schema(tenant) - else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) - if not all_tenants: - self._notice("No tenants found") - - for tenant in all_tenants: - Migrations._dependencies_done = False # very important, the dependencies need to be purged from cache - self._notice("=== Running migrate for schema %s" % tenant.schema_name) - self._migrate_schema(tenant) - - self._restore_south_settings() - - def migrate_public_apps(self): - self._save_south_settings() - - apps = self.shared_apps or self.installed_apps - self._set_managed_apps(included_apps=apps, excluded_apps=self.tenant_apps) - - self._notice("=== Running migrate for schema public") - MigrateCommand().execute(*self.args, **self.options) - - self._clear_south_cache() - self._restore_south_settings() diff --git a/tenant_schemas/management/commands/migrate.py b/tenant_schemas/management/commands/migrate.py index 5a8deec..55eee06 100644 --- a/tenant_schemas/management/commands/migrate.py +++ b/tenant_schemas/management/commands/migrate.py @@ -1,6 +1,7 @@ -import django from django.conf import settings from django.core.management.base import CommandError, BaseCommand + +from tenant_schemas.management.commands.migrate_schemas import Command as MigrateSchemasCommand from tenant_schemas.utils import django_is_in_test_mode try: @@ -21,6 +22,5 @@ class Command(MigrateCommand): super(Command, self).handle(*args, **options) -if django.VERSION >= (1, 7, 0) and django_is_in_test_mode(): - from .migrate_schemas import MigrateSchemasCommand +if django_is_in_test_mode(): Command = MigrateSchemasCommand diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 3178600..922bee5 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -1,17 +1,12 @@ -import django -from optparse import NO_DEFAULT - -if django.VERSION >= (1, 7, 0): - from django.core.management.commands.migrate import Command as MigrateCommand - from django.db.migrations.recorder import MigrationRecorder -from django.db import connection from django.conf import settings +from django.core.management.commands.migrate import Command as MigrateCommand +from django.db import connection -from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists from tenant_schemas.management.commands import SyncCommon +from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists -class MigrateSchemasCommand(SyncCommon): +class Command(SyncCommon): help = "Updates database schema. Manages both apps with migrations and those without." def __init__(self, stdout=None, stderr=None, no_color=False): @@ -19,18 +14,15 @@ class MigrateSchemasCommand(SyncCommon): Changes the option_list to use the options from the wrapped migrate command. """ self.option_list += MigrateCommand.option_list - if django.VERSION >= (1, 8, 0): - super(MigrateSchemasCommand, self).__init__(stdout, stderr, no_color) - else: - super(MigrateSchemasCommand, self).__init__() + super(Command, self).__init__(stdout, stderr, no_color) def add_arguments(self, parser): - super(MigrateSchemasCommand, self).add_arguments(parser) + super(Command, self).add_arguments(parser) command = MigrateCommand() command.add_arguments(parser) def handle(self, *args, **options): - super(MigrateSchemasCommand, self).handle(*args, **options) + super(Command, self).handle(*args, **options) self.PUBLIC_SCHEMA_NAME = get_public_schema_name() if self.sync_public and not self.schema_name: @@ -60,9 +52,3 @@ class MigrateSchemasCommand(SyncCommon): def _notice(self, output): self.stdout.write(self.style.NOTICE(output)) - - -if django.VERSION >= (1, 7, 0): - Command = MigrateSchemasCommand -else: - from .legacy.migrate_schemas import Command diff --git a/tenant_schemas/management/commands/sync_schemas.py b/tenant_schemas/management/commands/sync_schemas.py deleted file mode 100644 index c324f6e..0000000 --- a/tenant_schemas/management/commands/sync_schemas.py +++ /dev/null @@ -1,57 +0,0 @@ -import django -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ImproperlyConfigured -from django.db.models import get_apps, get_models -if "south" in settings.INSTALLED_APPS: - from south.management.commands.syncdb import Command as SyncdbCommand -else: - from django.core.management.commands.syncdb import Command as SyncdbCommand -from django.db import connection -from tenant_schemas.utils import get_tenant_model, get_public_schema_name -from tenant_schemas.management.commands import SyncCommon - - -class Command(SyncCommon): - help = "Sync schemas based on TENANT_APPS and SHARED_APPS settings" - option_list = SyncdbCommand.option_list + SyncCommon.option_list - - def handle(self, *args, **options): - if django.VERSION >= (1, 7, 0): - raise RuntimeError('This command is only meant to be used for 1.6' - ' and older version of django. For 1.7, use' - ' `migrate_schemas` instead.') - super(Command, self).handle(*args, **options) - - if "south" in settings.INSTALLED_APPS: - self.options["migrate"] = False - - # Content types may be different on tenants, so reset the cache - ContentType.objects.clear_cache() - - if self.sync_public: - self.sync_public_apps() - if self.sync_tenant: - self.sync_tenant_apps(self.schema_name) - - def _sync_tenant(self, tenant): - if int(self.options.get('verbosity', 1)) >= 1: - self._notice("=== Running syncdb for schema: %s" % tenant.schema_name) - connection.set_tenant(tenant, include_public=False) - SyncdbCommand().execute(**self.options) - - def sync_tenant_apps(self, schema_name=None): - if schema_name: - tenant = get_tenant_model().objects.filter(schema_name=schema_name).get() - self._sync_tenant(tenant) - else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) - if not all_tenants: - self._notice("No tenants found!") - - for tenant in all_tenants: - self._sync_tenant(tenant) - - def sync_public_apps(self): - SyncdbCommand().execute(**self.options) - self._notice("=== Running syncdb for schema public") diff --git a/tenant_schemas/management/commands/syncdb.py b/tenant_schemas/management/commands/syncdb.py deleted file mode 100644 index 9683e57..0000000 --- a/tenant_schemas/management/commands/syncdb.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.core.management.base import CommandError -from django.conf import settings -from tenant_schemas.utils import django_is_in_test_mode - -if 'south' in settings.INSTALLED_APPS: - from south.management.commands import syncdb -else: - from django.core.management.commands import syncdb - - -class Command(syncdb.Command): - - def handle(self, *args, **options): - database = options.get('database', 'default') - if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' and not - django_is_in_test_mode()): - raise CommandError("syncdb has been disabled, for database '{0}'. " - "Use sync_schemas instead. Please read the " - "documentation if you don't know why " - "you shouldn't call syncdb directly!".format(database)) - super(Command, self).handle(*args, **options) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 672fb04..139cf3a 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -93,27 +93,9 @@ class TenantMixin(models.Model): cursor.execute('CREATE SCHEMA %s' % self.schema_name) if sync_schema: - if django.VERSION >= (1, 7, 0,): - call_command('migrate_schemas', - schema_name=self.schema_name, - interactive=False, - verbosity=verbosity) - else: - # default is faking all migrations and syncing directly to the current models state - fake_all_migrations = getattr(settings, 'TENANT_CREATION_FAKES_MIGRATIONS', True) - call_command('sync_schemas', - schema_name=self.schema_name, - tenant=True, - public=False, - interactive=False, - migrate_all=fake_all_migrations, - verbosity=verbosity) - - # run/fake all migrations - if 'south' in settings.INSTALLED_APPS and not django_is_in_test_mode(): - call_command('migrate_schemas', - fake=fake_all_migrations, - schema_name=self.schema_name, - verbosity=verbosity) + call_command('migrate_schemas', + schema_name=self.schema_name, + interactive=False, + verbosity=verbosity) connection.set_schema_to_public() diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index b7af082..fb27678 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -1,4 +1,4 @@ -import django +from django.db.backends.base.introspection import TableInfo from django.db.backends.postgresql_psycopg2.introspection import ( DatabaseIntrospection, FieldInfo, @@ -7,11 +7,7 @@ from django.utils.encoding import force_text def _build_table_info(row): - if django.VERSION < (1, 8, 0): - return row[0] - else: - from django.db.backends.base.introspection import TableInfo - return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) + return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) def _build_field_info(col, field_map): @@ -23,10 +19,8 @@ def _build_field_info(col, field_map): # is nullable info_args.append(field_map[col_name][0] == 'YES') - # django 1.8 added default value to FieldInfo - if django.VERSION >= (1, 8, 0): - # default value - info_args.append(field_map[col_name][1]) + # default value + info_args.append(field_map[col_name][1]) return FieldInfo(*info_args) @@ -193,11 +187,6 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): Returns a dictionary of {field_name: (field_name_other_table, other_table)} representing all relationships to the given table. - - Django 1.7: - Returns a dictionary of - {field_index: (field_index_other_table, other_table)} - representing all relationships to the given table. Indexes are 0-based. """ cursor.execute(self._get_relations_query, { 'schema': self.connection.schema_name, @@ -205,12 +194,7 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): }) relations = {} for row in cursor.fetchall(): - if django.VERSION < (1, 8, 0): - # row[3] and row[4] are single-item lists, - # so grab the single item. - relations[row[3][0] - 1] = (row[4][0] - 1, row[0]) - else: - relations[row[1]] = (row[2], row[0]) + relations[row[1]] = (row[2], row[0]) return relations def get_key_columns(self, cursor, table_name): diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index dd8c45b..ec88c34 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -6,81 +6,92 @@ multi-tenant setting import hashlib from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.template.base import TemplateDoesNotExist -from django.template.loader import (BaseLoader, get_template_from_string, - find_template_loader, make_origin) +from django.template.base import TemplateDoesNotExist, Template from django.utils.encoding import force_bytes from django.utils._os import safe_join from django.db import connection -from tenant_schemas.postgresql_backend.base import FakeTenant +from django.template.loaders.base import Loader as BaseLoader +from tenant_schemas.postgresql_backend.base import FakeTenant +import django class CachedLoader(BaseLoader): is_usable = True - def __init__(self, loaders): + def __init__(self, engine, loaders): self.template_cache = {} - self._loaders = loaders - self._cached_loaders = [] + self.find_template_cache = {} + self.loaders = engine.get_template_loaders(loaders) + super(CachedLoader, self).__init__(engine) - @property - def loaders(self): - # Resolve loaders on demand to avoid circular imports - if not self._cached_loaders: - # Set self._cached_loaders atomically. Otherwise, another thread - # could see an incomplete list. See #17303. - cached_loaders = [] - for loader in self._loaders: - cached_loaders.append(find_template_loader(loader)) - self._cached_loaders = cached_loaders - return self._cached_loaders + @staticmethod + def cache_key(template_name, template_dirs): + if connection.tenant and template_dirs: + return '-'.join([str(connection.tenant.pk), template_name, + hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) + if template_dirs: + # If template directories were specified, use a hash to differentiate + return '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) + else: + return template_name def find_template(self, name, dirs=None): - for loader in self.loaders: - try: - template, display_name = loader(name, dirs) - return template, make_origin(display_name, loader, name, dirs) - except TemplateDoesNotExist: - pass - raise TemplateDoesNotExist(name) + """ + Helper method. Lookup the template :param name: in all the configured loaders + """ + key = self.cache_key(name, dirs) + try: + result = self.find_template_cache[key] + except KeyError: + result = None + for loader in self.loaders: + try: + template, display_name = loader(name, dirs) + except TemplateDoesNotExist: + pass + else: + origin = self.engine.make_origin(display_name, loader, name, dirs) + result = template, origin + break + self.find_template_cache[key] = result + if result: + return result + else: + self.template_cache[key] = TemplateDoesNotExist + raise TemplateDoesNotExist(name) def load_template(self, template_name, template_dirs=None): - if connection.tenant: - key = '-'.join([str(connection.tenant.pk), template_name]) - else: - key = template_name - if template_dirs: - # If template directories were specified, use a hash to - # differentiate - if connection.tenant: - key = '-'.join([str(connection.tenant.pk), template_name, - hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) - else: - key = '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) - - if key not in self.template_cache: + key = self.cache_key(template_name, template_dirs) + template_tuple = self.template_cache.get(key) + # A cached previous failure: + if template_tuple is TemplateDoesNotExist: + raise TemplateDoesNotExist + elif template_tuple is None: template, origin = self.find_template(template_name, template_dirs) if not hasattr(template, 'render'): try: - template = get_template_from_string(template, origin, template_name) + template = Template(template, origin, template_name, self.engine) except TemplateDoesNotExist: # If compiling the template we found raises TemplateDoesNotExist, # back off to returning the source and display name for the template # we were asked to load. This allows for correct identification (later) # of the actual template that does not exist. - return template, origin - self.template_cache[key] = template - return self.template_cache[key], None + self.template_cache[key] = (template, origin) + self.template_cache[key] = (template, None) + return self.template_cache[key] def reset(self): - "Empty the template cache." + """ + Empty the template cache. + """ self.template_cache.clear() class FilesystemLoader(BaseLoader): is_usable = True - def get_template_sources(self, template_name, template_dirs=None): + @staticmethod + def get_template_sources(template_name, template_dirs=None): """ Returns the absolute paths to "template_name", when appended to each directory in "template_dirs". Any paths that don't lie inside one of the @@ -97,9 +108,9 @@ class FilesystemLoader(BaseLoader): for template_dir in template_dirs: try: if '%s' in template_dir: - yield safe_join(template_dir % connection.tenant.domain_url, template_name) + yield safe_join(template_dir % connection.tenant.schema_name, template_name) else: - yield safe_join(template_dir, connection.tenant.domain_url, template_name) + yield safe_join(template_dir, connection.tenant.schema_name, template_name) except UnicodeDecodeError: # The template dir name was a bytestring that wasn't valid UTF-8. raise @@ -114,7 +125,7 @@ class FilesystemLoader(BaseLoader): for filepath in self.get_template_sources(template_name, template_dirs): try: with open(filepath, 'rb') as fp: - return (fp.read().decode(settings.FILE_CHARSET), filepath) + return fp.read().decode(settings.FILE_CHARSET), filepath except IOError: tried.append(filepath) if tried: diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index 1d0dbcd..528a7f1 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -1,10 +1,9 @@ -import django from django.core.management import call_command from django.db import connection from django.test import TestCase -from tenant_schemas.utils import get_tenant_model from tenant_schemas.utils import get_public_schema_name +from tenant_schemas.utils import get_tenant_model class TenantTestCase(TestCase): @@ -27,17 +26,7 @@ class TenantTestCase(TestCase): @classmethod def sync_shared(cls): - if django.VERSION >= (1, 7, 0): - call_command('migrate_schemas', - schema_name=get_public_schema_name(), - interactive=False, - verbosity=0) - else: - call_command('sync_schemas', - schema_name=get_public_schema_name(), - tenant=False, - public=True, - interactive=False, - migrate_all=True, - verbosity=0, - ) + call_command('migrate_schemas', + schema_name=get_public_schema_name(), + interactive=False, + verbosity=0) diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 8ac0645..4ae763a 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -1,12 +1,12 @@ -import django import json +from StringIO import StringIO + from django.conf import settings from django.contrib.auth.models import User from django.core.management import call_command from django.db import connection -from StringIO import StringIO - from dts_test_app.models import DummyModel, ModelWithFkToPublicUser + from tenant_schemas.test.cases import TenantTestCase from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant from tenant_schemas.tests.testcases import BaseTestCase @@ -124,7 +124,7 @@ class TenantSyncTest(BaseTestCase): Tests if the shared apps and the tenant apps get synced correctly depending on if the public schema or a tenant is being synced. """ - MIGRATION_TABLE_SIZE = 1 if django.VERSION >= (1, 7, 0) else 0 + MIGRATION_TABLE_SIZE = 1 def test_shared_apps_does_not_sync_tenant_apps(self): """ @@ -217,12 +217,11 @@ class TenantCommandTest(BaseTestCase): Tenant(domain_url='localhost', schema_name='public').save(verbosity=BaseTestCase.get_verbosity()) out = StringIO() - if django.VERSION >= (1, 8, 0): - call_command('tenant_command', args=('dumpdata', 'tenant_schemas'), natural_foreign=True, - schema_name=get_public_schema_name(), stdout=out) - else: - call_command('tenant_command', 'dumpdata', 'tenant_schemas', natural_foreign=True, - schema_name=get_public_schema_name(), stdout=out) + call_command('tenant_command', + args=('dumpdata', 'tenant_schemas'), + natural_foreign=True, + schema_name=get_public_schema_name(), + stdout=out) self.assertItemsEqual( json.loads('[{"fields": {"domain_url": "localhost", "schema_name": "public"}, ' '"model": "tenant_schemas.tenant", "pk": 1}]'), diff --git a/tenant_schemas/tests/test_utils.py b/tenant_schemas/tests/test_utils.py index 931d5ce..a740240 100644 --- a/tenant_schemas/tests/test_utils.py +++ b/tenant_schemas/tests/test_utils.py @@ -3,7 +3,7 @@ from __future__ import absolute_import import sys import types -import django +from django.apps import AppConfig from django.test import TestCase from tenant_schemas import utils @@ -28,34 +28,11 @@ class AppLabelsTestCase(TestCase): sys.modules[name] = module return sys.modules[whole_name] - def test_app_labels_1_7(self): + def test_app_labels(self): """ Verifies that app_labels handle Django 1.7+ AppConfigs properly. https://docs.djangoproject.com/en/1.7/ref/applications/ """ - if django.VERSION >= (1, 7): - from django.apps import AppConfig - else: - # set up poor man's mock for 1.7/1.8 behaviour. - from collections import namedtuple - - class AppConfig(object): - call_count = 0 - ret_map = { - 'example1': 'example1', - 'example2.apps.Example2AppConfig': 'example2_app', - } - - @classmethod - def create(cls, app): - return namedtuple('AppConfig', ['label'])( - cls.ret_map[app] - ) - dj_apps = self.set_up_module('django.apps') - dj_apps.AppConfig = AppConfig - - reload(utils) - self.set_up_module('example1') apps = self.set_up_module('example2.apps') @@ -74,24 +51,3 @@ class AppLabelsTestCase(TestCase): ]), ['example1', 'example2_app'], ) - - def test_app_labels_pre_1_7(self): - if django.VERSION >= (1, 7): - # set AppConfig to None and reload utils module - from django import apps - AppConfig = apps.AppConfig - apps.AppConfig = None - - reload(utils) - - self.assertEqual( - utils.app_labels( - ['example1', 'example2.apps.Example2AppConfig'] - ), - ['example1', 'Example2AppConfig'] - ) - - if django.VERSION >= (1, 7): - # restore AppConfig - apps.AppConfig = AppConfig - reload(utils) diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 2d4f828..25ff443 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -52,17 +52,8 @@ class BaseTestCase(TestCase): @classmethod def sync_shared(cls): - if django.VERSION >= (1, 7, 0): - call_command('migrate_schemas', - schema_name=get_public_schema_name(), - interactive=False, - verbosity=cls.get_verbosity(), - run_syncdb=True) - else: - call_command('sync_schemas', - schema_name=get_public_schema_name(), - tenant=False, - public=True, - interactive=False, - migrate_all=True, - verbosity=cls.get_verbosity()) + call_command('migrate_schemas', + schema_name=get_public_schema_name(), + interactive=False, + verbosity=cls.get_verbosity(), + run_syncdb=True) diff --git a/tox.ini b/tox.ini index bce61cd..91b45a5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27-dj{17,18,19} +envlist = py27-dj{18,19} [testenv] basepython = @@ -7,7 +7,6 @@ basepython = usedevelop = True deps = - dj17: Django~=1.7.0 dj18: Django~=1.8.0 dj19: Django~=1.9.0 From 63ae15f1b833453eeb674454f2d4f4886ce2ae07 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 30 May 2016 00:08:23 +0200 Subject: [PATCH 023/157] Changed readthedocs.org to readthedocs.io --- README.rst | 8 ++++---- docs/test.rst | 2 +- examples/tenant_tutorial/templates/base.html | 2 +- examples/tenant_tutorial/templates/index_public.html | 5 ++--- tenant_schemas/management/commands/__init__.py | 2 +- tenant_schemas/routers.py | 1 + 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index c885f2c..0245d40 100644 --- a/README.rst +++ b/README.rst @@ -102,7 +102,7 @@ Setup & Documentation **This is just a short setup guide**, it is **strongly** recommended that you read the complete version at -`django-tenant-schemas.readthedocs.org`_. +`django-tenant-schemas.readthedocs.io`_. Your ``DATABASE_ENGINE`` setting needs to be changed to @@ -192,7 +192,7 @@ so you shouldn’t need to change anything at your views. You’re all set, but we have left key details outside of this short tutorial, such as creating the public tenant and configuring shared and tenant specific apps. Complete instructions can be found at -`django-tenant-schemas.readthedocs.org`_. +`django-tenant-schemas.readthedocs.io`_. @@ -205,5 +205,5 @@ tenant specific apps. Complete instructions can be found at :target: https://pypi.python.org/pypi/django-tenant-schemas .. |PyPi downloads| image:: https://img.shields.io/pypi/dm/django-tenant-schemas.svg :target: https://pypi.python.org/pypi/django-tenant-schemas -.. _setup: https://django-tenant-schemas.readthedocs.org/en/latest/install.html -.. _django-tenant-schemas.readthedocs.org: https://django-tenant-schemas.readthedocs.org/en/latest/ +.. _setup: https://django-tenant-schemas.readthedocs.io/en/latest/install.html +.. _django-tenant-schemas.readthedocs.io: https://django-tenant-schemas.readthedocs.io/en/latest/ diff --git a/docs/test.rst b/docs/test.rst index 1834a68..3e27c59 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -32,4 +32,4 @@ Because django will not create tenants for you during your tests, we have packed response = self.c.get(reverse('user_profile')) self.assertEqual(response.status_code, 200) -.. _tox: https://tox.readthedocs.org/ +.. _tox: https://tox.readthedocs.io/ diff --git a/examples/tenant_tutorial/templates/base.html b/examples/tenant_tutorial/templates/base.html index 3b77cd2..fe0373a 100644 --- a/examples/tenant_tutorial/templates/base.html +++ b/examples/tenant_tutorial/templates/base.html @@ -52,7 +52,7 @@ {{ tenant.name }} · {% endfor %}

- django-tenant-schemas · Documentation + django-tenant-schemas · Documentation

diff --git a/examples/tenant_tutorial/templates/index_public.html b/examples/tenant_tutorial/templates/index_public.html index 3b4a0aa..64a79c8 100644 --- a/examples/tenant_tutorial/templates/index_public.html +++ b/examples/tenant_tutorial/templates/index_public.html @@ -124,9 +124,8 @@ Client(domain_url='tenant2.trendy-sass.com',

Where to go from here

There are some interesting features that we did not cover.

{% endif %} {% endblock %} diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 4542d03..e4865bf 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -100,7 +100,7 @@ class InteractiveTenantOption(object): if not all_tenants: raise CommandError("""There are no tenants in the system. To learn how create a tenant, see: -https://django-tenant-schemas.readthedocs.org/en/latest/use.html#creating-a-tenant""") +https://django-tenant-schemas.readthedocs.io/en/latest/use.html#creating-a-tenant""") if options.get('schema_name'): tenant_schema = options['schema_name'] diff --git a/tenant_schemas/routers.py b/tenant_schemas/routers.py index ec484e2..4452d86 100644 --- a/tenant_schemas/routers.py +++ b/tenant_schemas/routers.py @@ -20,6 +20,7 @@ class TenantSyncRouter(object): if connection.schema_name == get_public_schema_name(): if app_label not in app_labels(settings.SHARED_APPS): + print app_label return False else: if app_label not in app_labels(settings.TENANT_APPS): From ad12a7461300c593013092286cb0157365ddad21 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 30 May 2016 00:10:09 +0200 Subject: [PATCH 024/157] Removing mentions to south --- tenant_schemas/management/commands/migrate.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tenant_schemas/management/commands/migrate.py b/tenant_schemas/management/commands/migrate.py index 55eee06..663ad33 100644 --- a/tenant_schemas/management/commands/migrate.py +++ b/tenant_schemas/management/commands/migrate.py @@ -4,13 +4,8 @@ from django.core.management.base import CommandError, BaseCommand from tenant_schemas.management.commands.migrate_schemas import Command as MigrateSchemasCommand from tenant_schemas.utils import django_is_in_test_mode -try: - from south.management.commands.migrate import Command as MigrateCommand -except ImportError: - MigrateCommand = BaseCommand - -class Command(MigrateCommand): +class Command(BaseCommand): def handle(self, *args, **options): database = options.get('database', 'default') From 7b4182aeca2494175ee07ea8685b0ca102483cce Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 30 May 2016 00:12:17 +0200 Subject: [PATCH 025/157] Removing mentions to south --- docs/install.rst | 2 +- docs/test.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index a564bea..528af61 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -153,7 +153,7 @@ Optional Settings :Default: ``'True'`` - Sets if the models will be synced directly to the last version and all migration subsequently faked. Useful in the cases where migrations can not be faked and need to be ran individually. Be aware that setting this to `False` may significantly slow down the process of creating tenants. Only relevant if `South `_ is used. + Sets if the models will be synced directly to the last version and all migration subsequently faked. Useful in the cases where migrations can not be faked and need to be ran individually. Be aware that setting this to `False` may significantly slow down the process of creating tenants. Tenant View-Routing ------------------- diff --git a/docs/test.rst b/docs/test.rst index 3e27c59..da6bf95 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -3,7 +3,7 @@ Tests ================== Running the tests ----------------- -If you're using South, don't forget to set ``SOUTH_TESTS_MIGRATE = False``. Run these tests from the project ``dts_test_project``, it comes prepacked with the correct settings file and extra apps to enable tests to ensure different apps can exist in ``SHARED_APPS`` and ``TENANT_APPS``. +Run these tests from the project ``dts_test_project``, it comes prepacked with the correct settings file and extra apps to enable tests to ensure different apps can exist in ``SHARED_APPS`` and ``TENANT_APPS``. .. code-block:: bash From a70979ed29e3d14309f3f406788360f2243cf0e5 Mon Sep 17 00:00:00 2001 From: Guillaume Andreu Sabater Date: Mon, 30 May 2016 17:05:19 +0200 Subject: [PATCH 026/157] removed lone print --- tenant_schemas/routers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tenant_schemas/routers.py b/tenant_schemas/routers.py index 4452d86..ec484e2 100644 --- a/tenant_schemas/routers.py +++ b/tenant_schemas/routers.py @@ -20,7 +20,6 @@ class TenantSyncRouter(object): if connection.schema_name == get_public_schema_name(): if app_label not in app_labels(settings.SHARED_APPS): - print app_label return False else: if app_label not in app_labels(settings.TENANT_APPS): From d1e84842526ba7e1208674145260959291008384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Poul?= Date: Wed, 22 Jun 2016 16:15:51 +0200 Subject: [PATCH 027/157] Wrote some docs for utils --- docs/use.rst | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/docs/use.rst b/docs/use.rst index 09015e1..bc329ea 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -125,6 +125,89 @@ Prints to standard output a tab separated list of schema:domain_url values for e done +Utils +----- + +There are several utils available in `tenant_schemas.utils` that can help you in writing more complicated applications. + + +schema_context +~~~~~~~~~~~~~~ + +This context manager activates a specific schema name. + +.. code-block:: python + + from tenant_schemas.utils import schema_context + + # runs in a schema + + with schema_context(schema_name): + # this will run in schema `schema_name` + + # runs back in the original schema + + +tenant_context +~~~~~~~~~~~~~~ + +This context manager is very similiar to the ``schema_context`` manager, +but it takes a whole tenant model object as the argument. + +.. code-block:: python + + from tenant_schemas.utils import tenant_context + + # runs in a schema + + with tenant_context(tenant): + # this will run in schema of the tenant `tenant` + + # runs back in the original schema + + + +schema_exists +~~~~~~~~~~~~~ + +Returns True if a schema exists in the current database. + +.. code-block:: python + + from django.core.exceptions import ValidationError + from django.utils.text import slugify + + from tenant_schemas.utils import schema_exists + + class TenantModelForm: + # ... + + def clean_schema_name(self) + schema_name = self.cleaned_data["schema_name"] + schema_name = slugify(schema_name).replace("-", "") + if schema_exists(schema_name): + raise ValidationError("A schema with this name already exists in the database") + else: + return schema_name + + +get_tenant_model +~~~~~~~~~~~~~~~~ + +Returns the class of the tenant model. + +get_public_schema_name +~~~~~~~~~~~~~~~~~~~~~~ + +Returns the name of the public schema (from settings or the default). + + +get_limit_set_calls +~~~~~~~~~~~~~~~~~~~ + +Returns the TENANT_LIMIT_SET_CALLS setting or the default (False). See bellow. + + Performance Considerations -------------------------- From 1a48f6c57b6b1cde3c153a5f9bc3ba7235ad146c Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jul 2016 07:12:46 +1000 Subject: [PATCH 028/157] Fix regression introduced in cb8862d - path construction changed from domain_url to schema_name --- tenant_schemas/template_loaders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index ec88c34..583793a 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -108,9 +108,9 @@ class FilesystemLoader(BaseLoader): for template_dir in template_dirs: try: if '%s' in template_dir: - yield safe_join(template_dir % connection.tenant.schema_name, template_name) + yield safe_join(template_dir % connection.tenant.domain_url, template_name) else: - yield safe_join(template_dir, connection.tenant.schema_name, template_name) + yield safe_join(template_dir, connection.tenant.domain_url, template_name) except UnicodeDecodeError: # The template dir name was a bytestring that wasn't valid UTF-8. raise From 515ef03e89b8b811efbc12de2edb83d57b5ef913 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 19 Jul 2016 07:22:10 +1000 Subject: [PATCH 029/157] Fix BaseTenantCommand to correctly add arguments from COMMAND_NAME A simple implementation class was not bringing the options through: class Command(BaseTenantCommand): COMMAND_NAME = 'collectstatic' Once this patch is applied, the expected output and behaviour is exhibited. --- .../management/commands/__init__.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 454c1e6..3dae6d7 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -26,20 +26,30 @@ class BaseTenantCommand(BaseCommand): app_name = get_commands()[obj.COMMAND_NAME] if isinstance(app_name, BaseCommand): # If the command is already loaded, use it directly. - cmdclass = app_name + obj._original_command = app_name else: - cmdclass = load_command_class(app_name, obj.COMMAND_NAME) + obj._original_command = load_command_class(app_name, obj.COMMAND_NAME) + + # prepend the command's original help with the info about schemata + # iteration + obj.help = ( + "Calls {cmd} for all registered schemata. You can use regular " + "{cmd} options.\n\nOriginal help for {cmd}:\n\n{help}".format( + cmd=obj.COMMAND_NAME, + help=getattr(obj._original_command, 'help', 'none'), + ) + ) - # prepend the command's original help with the info about schemata iteration - obj.help = "Calls %s for all registered schemata. You can use regular %s options. " \ - "Original help for %s: %s" % (obj.COMMAND_NAME, obj.COMMAND_NAME, obj.COMMAND_NAME, - getattr(cmdclass, 'help', 'none')) return obj def add_arguments(self, parser): super(BaseTenantCommand, self).add_arguments(parser) parser.add_argument("-s", "--schema", dest="schema_name") - parser.add_argument("-p", "--skip-public", dest="skip_public", action="store_true", default=False) + parser.add_argument("-p", "--skip-public", dest="skip_public", + action="store_true", default=False) + # use the privately held reference to the underlying command to invoke + # the add_arguments path on this parser instance + self._original_command.add_arguments(parser) def execute_command(self, tenant, command_name, *args, **options): verbosity = int(options.get('verbosity')) From 13b9a1eeae7105ccac182e16f0b67a126e49c2e8 Mon Sep 17 00:00:00 2001 From: Christian Felipe Date: Wed, 20 Jul 2016 12:20:00 +0200 Subject: [PATCH 030/157] check db wrapper subclases in dbrouter --- tenant_schemas/routers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/routers.py b/tenant_schemas/routers.py index ed5c784..5702e81 100644 --- a/tenant_schemas/routers.py +++ b/tenant_schemas/routers.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db.models.base import ModelBase +from django.db.utils import load_backend class TenantSyncRouter(object): @@ -13,8 +14,11 @@ class TenantSyncRouter(object): # https://code.djangoproject.com/ticket/20704 from django.db import connection from tenant_schemas.utils import get_public_schema_name, app_labels + from tenant_schemas.postgresql_backend.base import DatabaseWrapper as TenantDbWrapper - if settings.DATABASES[db]['ENGINE'] != 'tenant_schemas.postgresql_backend': + db_engine = settings.DATABASES[db]['ENGINE'] + if not (db_engine == 'tenant_schemas.postgresql_backend' or + issubclass(getattr(load_backend(db_engine), 'DatabaseWrapper'), TenantDbWrapper)): return None if isinstance(app_label, ModelBase): From f7d539f93571244aa8f64cfff39dc9fbefc3a73b Mon Sep 17 00:00:00 2001 From: Fitblip Date: Fri, 22 Jul 2016 17:58:16 -0700 Subject: [PATCH 031/157] Make sure hostname is always lowered before match There are some clients/libraries that won't call `.lower()` themselves on the url (unlike a standard web browser), so this should probably be done just to hedge against those cases. LMK if you have any questions! --- tenant_schemas/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index cb19ea6..16d3b1c 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -19,7 +19,7 @@ class TenantMiddleware(object): """ Extracts hostname from request. Used for custom requests filtering. By default removes the request's port and common prefixes. """ - return remove_www(request.get_host().split(':')[0]) + return remove_www(request.get_host().split(':')[0]).lower() def process_request(self, request): # Connection needs first to be at the public schema, as this is where From e8b7f2b99253ded14441b5403949467d1d956a1f Mon Sep 17 00:00:00 2001 From: Richard Stephens Date: Wed, 24 Aug 2016 15:17:37 +1000 Subject: [PATCH 032/157] fix compatibility issues with Django 1.10 --- tenant_schemas/management/commands/migrate_schemas.py | 5 ++++- tenant_schemas/middleware.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 922bee5..66a34ec 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -1,6 +1,8 @@ from django.conf import settings from django.core.management.commands.migrate import Command as MigrateCommand from django.db import connection +import django + from tenant_schemas.management.commands import SyncCommon from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists @@ -13,7 +15,8 @@ class Command(SyncCommon): """ Changes the option_list to use the options from the wrapped migrate command. """ - self.option_list += MigrateCommand.option_list + if django.VERSION <= (1,10,0): + self.option_list += MigrateCommand.option_list super(Command, self).__init__(stdout, stderr, no_color) def add_arguments(self, parser): diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index cb19ea6..1dc9986 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -5,9 +5,10 @@ from django.db import connection from django.http import Http404 from tenant_schemas.utils import (get_tenant_model, remove_www, get_public_schema_name) +from django.utils.deprecation import MiddlewareMixin -class TenantMiddleware(object): +class TenantMiddleware(MiddlewareMixin): """ This middleware should be placed at the very top of the middleware stack. Selects the proper database schema using the request host. Can fail in From 1427ccd5ad2b91cab88c604e5956fd82053a6f56 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Wed, 24 Aug 2016 18:14:36 +1000 Subject: [PATCH 033/157] Add Django 1.10 to list of tested versions --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 91b45a5..2a9b6fd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27-dj{18,19} +envlist = py27-dj{18,19,110} [testenv] basepython = @@ -9,6 +9,7 @@ usedevelop = True deps = dj18: Django~=1.8.0 dj19: Django~=1.9.0 + dj110: Django~=1.10.0 changedir = dts_test_project From 65bf9dab0772f60d72205aed331e901f6d5fb5d5 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Wed, 21 Sep 2016 21:01:51 +0200 Subject: [PATCH 034/157] Restored backwards compatibility --- tenant_schemas/management/commands/migrate_schemas.py | 5 +++-- tenant_schemas/middleware.py | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 66a34ec..8fe3592 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -1,7 +1,8 @@ +import django + from django.conf import settings from django.core.management.commands.migrate import Command as MigrateCommand from django.db import connection -import django from tenant_schemas.management.commands import SyncCommon @@ -15,7 +16,7 @@ class Command(SyncCommon): """ Changes the option_list to use the options from the wrapped migrate command. """ - if django.VERSION <= (1,10,0): + if django.VERSION <= (1, 10, 0): self.option_list += MigrateCommand.option_list super(Command, self).__init__(stdout, stderr, no_color) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 1dc9986..4ea704c 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -1,3 +1,5 @@ +import django + from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import DisallowedHost @@ -5,10 +7,13 @@ from django.db import connection from django.http import Http404 from tenant_schemas.utils import (get_tenant_model, remove_www, get_public_schema_name) -from django.utils.deprecation import MiddlewareMixin +if django.VERSION >= (1, 10, 0): + MIDDLEWARE_MIXIN = django.utils.deprecation.MiddlewareMixin +else: + MIDDLEWARE_MIXIN = object -class TenantMiddleware(MiddlewareMixin): +class TenantMiddleware(MIDDLEWARE_MIXIN): """ This middleware should be placed at the very top of the middleware stack. Selects the proper database schema using the request host. Can fail in From baf604644c856a0f68456ad5c9cd9376e5a93b03 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Wed, 21 Sep 2016 21:14:35 +0200 Subject: [PATCH 035/157] Added missing schema exception for Django 1.8 --- tenant_schemas/management/commands/migrate_schemas.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 0eb43c4..7886ff3 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -3,7 +3,12 @@ import django from django.conf import settings from django.core.management.commands.migrate import Command as MigrateCommand from django.db import connection -from django.db.migrations.exceptions import MigrationSchemaMissing + +if django.VERSION >= (1, 9, 0): + from django.db.migrations.exceptions import MigrationSchemaMissing +else: + class MigrationSchemaMissing(django.db.utils.DatabaseError): + pass from tenant_schemas.management.commands import SyncCommon From aa2f36a3a889413a00717b9452f7ad56e8fd1b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Thu, 22 Sep 2016 16:10:44 +0200 Subject: [PATCH 036/157] Fix typo --- tenant_schemas/postgresql_backend/introspection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index fb27678..b47746a 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -88,7 +88,7 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): AND n.nspname = %(schema)s """ - _get_constaints_query = """ + _get_constraints_query = """ SELECT kc.constraint_name, kc.column_name, @@ -240,7 +240,7 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): # Loop over the key table, collecting things as constraints # This will get PKs, FKs, and uniques, but not CHECK - cursor.execute(self._get_constaints_query, { + cursor.execute(self._get_constraints_query, { 'schema': self.connection.schema_name, 'table': table_name, }) From 077b8c1b453b05641c2c9bd242981adc820a8ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Thu, 22 Sep 2016 18:25:17 +0200 Subject: [PATCH 037/157] Style fix --- tenant_schemas/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 0e18d92..26eec68 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -12,7 +12,6 @@ except ImportError: from django.core import mail - @contextmanager def schema_context(schema_name): previous_tenant = connection.tenant From e431105a4b0a3bae12c8d30274d521211c72d74c Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 23 Sep 2016 09:41:38 +0200 Subject: [PATCH 038/157] Fix query performance (avoid full scan). --- tenant_schemas/postgresql_backend/introspection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index fb27678..58bc82a 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -88,14 +88,15 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): AND n.nspname = %(schema)s """ - _get_constaints_query = """ + _get_constraints_query = """ SELECT kc.constraint_name, kc.column_name, c.constraint_type, array(SELECT table_name::text || '.' || column_name::text FROM information_schema.constraint_column_usage - WHERE constraint_name = kc.constraint_name) + WHERE constraint_name = kc.constraint_name + AND table_schema = kc.table_schema) FROM information_schema.key_column_usage AS kc JOIN information_schema.table_constraints AS c ON kc.table_schema = c.table_schema AND @@ -240,7 +241,7 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): # Loop over the key table, collecting things as constraints # This will get PKs, FKs, and uniques, but not CHECK - cursor.execute(self._get_constaints_query, { + cursor.execute(self._get_constraints_query, { 'schema': self.connection.schema_name, 'table': table_name, }) From ff232f278d3eb4e54d5b02ba3c22540571f48238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Fri, 23 Sep 2016 12:04:54 +0200 Subject: [PATCH 039/157] Remove unused imports --- tenant_schemas/models.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 139cf3a..7907ac7 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -1,12 +1,9 @@ -import django -from django.conf import settings -from django.db import models, connection from django.core.management import call_command +from django.db import connection, models from tenant_schemas.postgresql_backend.base import _check_schema_name from tenant_schemas.signals import post_schema_sync -from tenant_schemas.utils import django_is_in_test_mode, schema_exists -from tenant_schemas.utils import get_public_schema_name +from tenant_schemas.utils import get_public_schema_name, schema_exists class TenantMixin(models.Model): From 31a29a160e0334f61d7998099b34b9326feb2951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Fri, 23 Sep 2016 12:11:38 +0200 Subject: [PATCH 040/157] - Added introspection support for UUIDField. - Added index order introspection. - Added index type introspection. - Allowed introspection to work regardless of table owner. - Added introspection for expression-based index. --- .../postgresql_backend/introspection.py | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index b47746a..255eddd 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -1,110 +1,119 @@ -from django.db.backends.base.introspection import TableInfo -from django.db.backends.postgresql_psycopg2.introspection import ( - DatabaseIntrospection, - FieldInfo, +from __future__ import unicode_literals + +from collections import namedtuple + +from django.db.backends.base.introspection import ( + BaseDatabaseIntrospection, FieldInfo, TableInfo, ) from django.utils.encoding import force_text - -def _build_table_info(row): - return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) +FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('default',)) -def _build_field_info(col, field_map): - col_name = force_text(col[0]) +class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): + # Maps type codes to Django Field types. + data_types_reverse = { + 16: 'BooleanField', + 17: 'BinaryField', + 20: 'BigIntegerField', + 21: 'SmallIntegerField', + 23: 'IntegerField', + 25: 'TextField', + 700: 'FloatField', + 701: 'FloatField', + 869: 'GenericIPAddressField', + 1042: 'CharField', # blank-padded + 1043: 'CharField', + 1082: 'DateField', + 1083: 'TimeField', + 1114: 'DateTimeField', + 1184: 'DateTimeField', + 1266: 'TimeField', + 1700: 'DecimalField', + 2950: 'UUIDField', + } - info_args = [col_name] - info_args.extend(col[1:6]) + ignored_tables = [] - # is nullable - info_args.append(field_map[col_name][0] == 'YES') - - # default value - info_args.append(field_map[col_name][1]) - - return FieldInfo(*info_args) - - -class DatabaseSchemaIntrospection(DatabaseIntrospection): _get_table_list_query = """ SELECT c.relname, c.relkind FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r', 'v', '') - AND n.nspname = %(schema)s - AND pg_catalog.pg_table_is_visible(c.oid) + WHERE c.relkind IN ('r', 'v') + AND n.nspname = %(schema)s; """ _get_table_description_query = """ SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = %(table)s - AND table_schema = %(schema)s + AND table_schema = %(schema)s """ _get_relations_query = """ - SELECT c2.relname, a1.attname, a2.attname, con.conkey, con.confkey - FROM pg_catalog.pg_constraint con - LEFT JOIN pg_catalog.pg_class c1 ON con.conrelid = c1.oid - LEFT JOIN pg_catalog.pg_class c2 ON con.confrelid = c2.oid - LEFT JOIN pg_catalog.pg_attribute a1 ON c1.oid = a1.attrelid - AND a1.attnum = con.conkey[1] - LEFT JOIN pg_catalog.pg_attribute a2 ON c2.oid = a2.attrelid - AND a2.attnum = con.confkey[1] - LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = c1.connamespace + SELECT c2.relname, a1.attname, a2.attname + FROM pg_constraint con + LEFT JOIN pg_class c1 ON con.conrelid = c1.oid + LEFT JOIN pg_class c2 ON con.confrelid = c2.oid + LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] + LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] + LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = con.connamespace WHERE c1.relname = %(table)s AND n1.nspname = %(schema)s AND con.contype = 'f' """ _get_key_columns_query = """ - SELECT kcu.column_name, ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column + SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column FROM information_schema.constraint_column_usage ccu - LEFT JOIN information_schema.key_column_usage kcu - ON ccu.constraint_catalog = kcu.constraint_catalog - AND ccu.constraint_schema = kcu.constraint_schema - AND ccu.constraint_name = kcu.constraint_name - LEFT JOIN information_schema.table_constraints tc - ON ccu.constraint_catalog = tc.constraint_catalog - AND ccu.constraint_schema = tc.constraint_schema - AND ccu.constraint_name = tc.constraint_name + LEFT JOIN information_schema.key_column_usage kcu + ON ccu.constraint_catalog = kcu.constraint_catalog + AND ccu.constraint_schema = kcu.constraint_schema + AND ccu.constraint_name = kcu.constraint_name + LEFT JOIN information_schema.table_constraints tc + ON ccu.constraint_catalog = tc.constraint_catalog + AND ccu.constraint_schema = tc.constraint_schema + AND ccu.constraint_name = tc.constraint_name WHERE kcu.table_name = %(table)s - AND kcu.table_schame = %(schema)s - AND tc.constraint_type = 'FOREIGN KEY' + AND kcu.table_schame = %(schema)s + AND tc.constraint_type = 'FOREIGN KEY' """ _get_indexes_query = """ SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, - pg_catalog.pg_index idx, pg_catalog.pg_attribute attr, - pg_catalog.pg_namespace n + pg_catalog.pg_index idx, pg_catalog.pg_attribute attr WHERE c.oid = idx.indrelid AND idx.indexrelid = c2.oid AND attr.attrelid = c.oid AND attr.attnum = idx.indkey[0] - AND c.relnamespace = n.oid AND c.relname = %(table)s AND n.nspname = %(schema)s """ _get_constraints_query = """ SELECT - kc.constraint_name, - kc.column_name, - c.constraint_type, - array(SELECT table_name::text || '.' || column_name::text - FROM information_schema.constraint_column_usage - WHERE constraint_name = kc.constraint_name) - FROM information_schema.key_column_usage AS kc - JOIN information_schema.table_constraints AS c ON - kc.table_schema = c.table_schema AND - kc.table_name = c.table_name AND - kc.constraint_name = c.constraint_name - WHERE - kc.table_schema = %(schema)s AND - kc.table_name = %(table)s - ORDER BY kc.ordinal_position ASC + c.conname, + array( + SELECT attname + FROM ( + SELECT unnest(c.conkey) AS colid, + generate_series(1, array_length(c.conkey, 1)) AS arridx + ) AS cols + JOIN pg_attribute AS ca ON cols.colid = ca.attnum + WHERE ca.attrelid = c.conrelid + ORDER BY cols.arridx + ), + c.contype, + (SELECT fkc.relname || '.' || fka.attname + FROM pg_attribute AS fka + JOIN pg_class AS fkc ON fka.attrelid = fkc.oid + WHERE fka.attrelid = c.confrelid + AND fka.attnum = c.confkey[1]) + FROM pg_constraint AS c + JOIN pg_class AS cl ON c.conrelid = cl.oid + JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid + WHERE ns.nspname = %(schema)s AND cl.relname = %(table)s """ _get_check_constraints_query = """ @@ -122,45 +131,63 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): _get_index_constraints_query = """ SELECT - c2.relname, - ARRAY( - SELECT ( - SELECT attname - FROM pg_catalog.pg_attribute - WHERE attnum = i AND attrelid = c.oid - ) - FROM unnest(idx.indkey) i - ), - idx.indisunique, - idx.indisprimary - FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, - pg_catalog.pg_index idx, pg_catalog.pg_namespace n - WHERE c.oid = idx.indrelid - AND idx.indexrelid = c2.oid - AND n.oid = c.relnamespace - AND c.relname = %(table)s - AND n.nspname = %(schema)s + indexname, array_agg(attname), indisunique, indisprimary, + array_agg(ordering), amname, exprdef + FROM ( + SELECT + c2.relname as indexname, idx.*, attr.attname, am.amname, + CASE + WHEN idx.indexprs IS NOT NULL THEN + pg_get_indexdef(idx.indexrelid) + END AS exprdef, + CASE + WHEN am.amcanorder THEN + CASE (option & 1) + WHEN 1 THEN 'DESC' ELSE 'ASC' + END + END as ordering + FROM ( + SELECT + *, unnest(i.indkey) as key, unnest(i.indoption) as option + FROM pg_index i + ) idx + LEFT JOIN pg_class c ON idx.indrelid = c.oid + LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid + LEFT JOIN pg_am am ON c2.relam = am.oid + LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key + LEFT JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.relname = %(table)s + AND n.nspname = %(schema)s + ) s2 + GROUP BY indexname, indisunique, indisprimary, amname, exprdef; """ + def get_field_type(self, data_type, description): + field_type = super(DatabaseSchemaIntrospection, self).get_field_type(data_type, description) + if description.default and 'nextval' in description.default: + if field_type == 'IntegerField': + return 'AutoField' + elif field_type == 'BigIntegerField': + return 'BigAutoField' + return field_type + def get_table_list(self, cursor): """ - Returns a list of table names in the current database and schema. + Returns a list of table and view names in the current schema. """ - cursor.execute(self._get_table_list_query, { - 'schema': self.connection.schema_name, + 'schema': self.connection.schema_name }) return [ - _build_table_info(row) + TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) for row in cursor.fetchall() if row[0] not in self.ignored_tables ] def get_table_description(self, cursor, table_name): """ - Returns a description of the table, with the DB-API - cursor.description interface. + Returns a description of the table, with the DB-API cursor.description interface. """ # As cursor.description does not return reliably the nullable property, @@ -169,38 +196,36 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 'schema': self.connection.schema_name, 'table': table_name }) - field_map = {line[0]: line[1:] for line in cursor.fetchall()} - - cursor.execute('SELECT * FROM %s.%s LIMIT 1' % ( - self.connection.schema_name, - self.connection.ops.quote_name(table_name), - )) + cursor.execute('SELECT * FROM %s LIMIT 1' % self.connection.ops.quote_name(table_name)) return [ - _build_field_info(line, field_map) - for line in cursor.description + FieldInfo(*( + (force_text(line[0]),) + + line[1:6] + + (field_map[force_text(line[0])][0] == 'YES', field_map[force_text(line[0])][1]) + )) for line in cursor.description ] def get_relations(self, cursor, table_name): """ - Returns a dictionary of - {field_name: (field_name_other_table, other_table)} + Returns a dictionary of {field_name: (field_name_other_table, other_table)} representing all relationships to the given table. """ cursor.execute(self._get_relations_query, { 'schema': self.connection.schema_name, - 'table': table_name, + 'table': table_name }) relations = {} for row in cursor.fetchall(): relations[row[1]] = (row[2], row[0]) + return relations def get_key_columns(self, cursor, table_name): cursor.execute(self._get_key_columns_query, { 'schema': self.connection.schema_name, - 'table': table_name, + 'table': table_name }) return list(cursor.fetchall()) @@ -211,20 +236,17 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 'schema': self.connection.schema_name, 'table': table_name, }) - indexes = {} for row in cursor.fetchall(): - # row[1] (idx.indkey) is stored in the DB as an array. - # It comes out as a string of space-separated integers. - # This designates the field indexes (1-based) of the fields - # that have indexes on the table. Here, we skip any indexes - # across multiple fields. + # row[1] (idx.indkey) is stored in the DB as an array. It comes out as + # a string of space-separated integers. This designates the field + # indexes (1-based) of the fields that have indexes on the table. + # Here, we skip any indexes across multiple fields. if ' ' in row[1]: continue if row[0] not in indexes: indexes[row[0]] = {'primary_key': False, 'unique': False} - # It's possible to have the unique and PK constraints - # in separate indexes. + # It's possible to have the unique and PK constraints in separate indexes. if row[3]: indexes[row[0]]['primary_key'] = True if row[2]: @@ -233,54 +255,31 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): def get_constraints(self, cursor, table_name): """ - Retrieves any constraints or keys (unique, pk, fk, check, index) - across one or more columns. + Retrieves any constraints or keys (unique, pk, fk, check, index) across + one or more columns. Also retrieve the definition of expression-based + indexes. """ constraints = {} - - # Loop over the key table, collecting things as constraints - # This will get PKs, FKs, and uniques, but not CHECK + # Loop over the key table, collecting things as constraints. The column + # array must return column names in the same order in which they were + # created + # The subquery containing generate_series can be replaced with + # "WITH ORDINALITY" when support for PostgreSQL 9.3 is dropped. cursor.execute(self._get_constraints_query, { 'schema': self.connection.schema_name, 'table': table_name, }) - for constraint, column, kind, used_cols in cursor.fetchall(): - # If we're the first column, make the record - if constraint not in constraints: - constraints[constraint] = { - "columns": [], - "primary_key": kind.lower() == "primary key", - "unique": kind.lower() in ["primary key", "unique"], - "foreign_key": - tuple(used_cols[0].split(".", 1)) - if kind.lower() == "foreign key" - else None, - "check": False, - "index": False, - } - # Record the details - constraints[constraint]['columns'].append(column) - - # Now get CHECK constraint columns - cursor.execute(self._get_check_constraints_query, { - 'schema': self.connection.schema_name, - 'table': table_name, - }) - - for constraint, column in cursor.fetchall(): - # If we're the first column, make the record - if constraint not in constraints: - constraints[constraint] = { - "columns": [], - "primary_key": False, - "unique": False, - "foreign_key": None, - "check": True, - "index": False, - } - # Record the details - constraints[constraint]['columns'].append(column) + for constraint, columns, kind, used_cols in cursor.fetchall(): + constraints[constraint] = { + "columns": columns, + "primary_key": kind == "p", + "unique": kind in ["p", "u"], + "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, + "check": kind == "c", + "index": False, + "definition": None, + } # Now get indexes cursor.execute(self._get_index_constraints_query, { @@ -288,15 +287,17 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 'table': table_name, }) - for index, columns, unique, primary in cursor.fetchall(): + for index, columns, unique, primary, orders, type_, definition in cursor.fetchall(): if index not in constraints: constraints[index] = { - "columns": list(columns), + "columns": columns if columns != [None] else [], + "orders": orders if orders != [None] else [], "primary_key": primary, "unique": unique, "foreign_key": None, "check": False, "index": True, + "type": type_, + "definition": definition, } - return constraints From d9ce4ff99a690e701edda5bdb227f93708371686 Mon Sep 17 00:00:00 2001 From: Justin Wunderle Date: Wed, 24 Aug 2016 17:49:13 +1000 Subject: [PATCH 041/157] Add TenantContextFilter - put schema_name and domain_url in log records Add a LOGGING configuration similar to the section below. LOGGING = { 'filters': { 'tenant_context': { '()': 'tenant_schemas.log.TenantContextFilter', }, }, 'formatters': { 'default': { 'format': '[%(schema_name)s:%(domain_url)s] ' '%(levelname)s %(asctime)s ' '%(message)s', }, }, 'handlers': { 'console': { 'class': 'logging.StreamHandler', 'formatter': 'default', 'filters': ['tenant_context'], }, }, } Your logging output will not look something like: [example:example.com] DEBUG 13:29 django.db.backends: (0.001) SELECT ... --- docs/use.rst | 40 +++++++++++++++++-- dts_test_project/dts_test_project/settings.py | 8 ++++ tenant_schemas/log.py | 15 +++++++ tenant_schemas/tests/__init__.py | 2 + tenant_schemas/tests/test_log.py | 16 ++++++++ tenant_schemas/utils.py | 2 - tox.ini | 4 +- 7 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 tenant_schemas/log.py create mode 100644 tenant_schemas/tests/test_log.py diff --git a/docs/use.rst b/docs/use.rst index a44ba61..9a6c611 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -68,7 +68,7 @@ To run only a particular schema, there is an optional argument called ``--schema ./manage.py migrate_schemas --schema=customer1 -migrate_schemas +migrate_schemas ~~~~~~~~~~~~~~~ ``migrate_schemas`` is the most important command on this app. The way it works is that it calls Django's ``migrate`` in two different ways. First, it calls ``migrate`` for the ``public`` schema, only syncing the shared apps. Then it runs ``migrate`` for every tenant in the database, this time only syncing the tenant apps. @@ -103,8 +103,8 @@ If you don't specify a schema, you will be prompted to enter one. Otherwise, you .. code-block:: bash ./manage.py tenant_command loaddata --schema=customer1 - -createsuperuser + +createsuperuser ~~~~~~~~~~~~~~~ The command ``createsuperuser`` is already automatically wrapped to have a ``schema`` flag. Create a new super user with @@ -210,6 +210,40 @@ get_limit_set_calls Returns the TENANT_LIMIT_SET_CALLS setting or the default (False). See bellow. +Logging +------- + +The optional ``TenantContextFilter`` can be included in ``settings.LOGGING`` to add the current ``schema_name`` and ``domain_url`` to the logging context. + +.. code-block:: python + + # settings.py + LOGGING = { + 'filters': { + 'tenant_context': { + '()': 'tenant_schemas.log.TenantContextFilter' + }, + }, + 'formatters': { + 'tenant_context': { + 'format': '[%(schema_name)s:%(domain_url)s] ' + '%(levelname)-7s %(asctime)s %(message)s', + }, + }, + 'handlers': { + 'console': { + 'filters': ['tenant_context'], + }, + }, + } + +This will result in logging output that looks similar to: + +.. code-block:: text + + [example:example.com] DEBUG 13:29 django.db.backends: (0.001) SELECT ... + + Performance Considerations -------------------------- diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 0be39cf..97489f6 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -125,11 +125,18 @@ LOGGING = { 'require_debug_false': { '()': 'django.utils.log.RequireDebugFalse' }, + 'tenant_context': { + '()': 'tenant_schemas.log.TenantContextFilter' + }, }, 'formatters': { 'simple': { 'format': '%(levelname)-7s %(asctime)s %(message)s', }, + 'tenant_context': { + 'format': '[%(schema_name)s:%(domain_url)s] ' + '%(levelname)-7s %(asctime)s %(message)s', + }, }, 'handlers': { 'null': { @@ -137,6 +144,7 @@ LOGGING = { }, 'console': { 'class': 'logging.StreamHandler', + 'filters': ['tenant_context'], 'formatter': 'simple', }, }, diff --git a/tenant_schemas/log.py b/tenant_schemas/log.py new file mode 100644 index 0000000..9a4e6b5 --- /dev/null +++ b/tenant_schemas/log.py @@ -0,0 +1,15 @@ +import logging + +from django.db import connection + + +class TenantContextFilter(logging.Filter): + """ + Add the current ``schema_name`` and ``domain_url`` to log records. + + Thanks to @regolith for the snippet on #248 + """ + def filter(self, record): + record.schema_name = connection.tenant.schema_name + record.domain_url = getattr(connection.tenant, 'domain_url', '') + return True diff --git a/tenant_schemas/tests/__init__.py b/tenant_schemas/tests/__init__.py index 99a2470..b5259a7 100644 --- a/tenant_schemas/tests/__init__.py +++ b/tenant_schemas/tests/__init__.py @@ -1,3 +1,5 @@ from test_routes import * from test_tenants import * from test_cache import * +from test_log import * +from test_utils import * diff --git a/tenant_schemas/tests/test_log.py b/tenant_schemas/tests/test_log.py new file mode 100644 index 0000000..a145d15 --- /dev/null +++ b/tenant_schemas/tests/test_log.py @@ -0,0 +1,16 @@ +import logging + +from django.test import TestCase + +from tenant_schemas import log + + +class LoggingFilterTests(TestCase): + + def test_tenant_context_filter(self): + filter_ = log.TenantContextFilter() + record = logging.makeLogRecord({}) + res = filter_.filter(record) + self.assertEqual(res, True) + self.assertEqual(record.schema_name, 'public') + self.assertEqual(record.domain_url, '') diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 0e18d92..9054125 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -12,7 +12,6 @@ except ImportError: from django.core import mail - @contextmanager def schema_context(schema_name): previous_tenant = connection.tenant @@ -117,4 +116,3 @@ def app_labels(apps_list): if AppConfig is None: return [app.split('.')[-1] for app in apps_list] return [AppConfig.create(app).label for app in apps_list] - diff --git a/tox.ini b/tox.ini index 2a9b6fd..8a2909e 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ basepython = usedevelop = True deps = + coverage dj18: Django~=1.8.0 dj19: Django~=1.9.0 dj110: Django~=1.10.0 @@ -16,4 +17,5 @@ changedir = dts_test_project passenv = PG_USER PG_PASSWORD commands = - {envpython} manage.py test tenant_schemas.tests --noinput -v 2 + coverage run manage.py test --noinput {posargs:tenant_schemas.tests -v 2} + coverage report -m --include=../tenant_schemas/* From b62cbd7fbadfdbfa24fa095d419ca0e39f1f0f4b Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Mon, 26 Sep 2016 07:48:06 +1000 Subject: [PATCH 042/157] Add TenantContextFilter to test project settings --- dts_test_project/dts_test_project/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 97489f6..dcd47ff 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -145,7 +145,7 @@ LOGGING = { 'console': { 'class': 'logging.StreamHandler', 'filters': ['tenant_context'], - 'formatter': 'simple', + 'formatter': 'tenant_context', }, }, 'loggers': { From 2b0b52267b5f9cae3f8bf60a2e23c286a6769178 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Thu, 29 Sep 2016 23:28:07 +0200 Subject: [PATCH 043/157] Revert "Add UUIDField, some index introspections and fix migrations performance issue" --- tenant_schemas/models.py | 7 +- .../postgresql_backend/introspection.py | 306 +++++++++--------- 2 files changed, 158 insertions(+), 155 deletions(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 7907ac7..139cf3a 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -1,9 +1,12 @@ +import django +from django.conf import settings +from django.db import models, connection from django.core.management import call_command -from django.db import connection, models from tenant_schemas.postgresql_backend.base import _check_schema_name from tenant_schemas.signals import post_schema_sync -from tenant_schemas.utils import get_public_schema_name, schema_exists +from tenant_schemas.utils import django_is_in_test_mode, schema_exists +from tenant_schemas.utils import get_public_schema_name class TenantMixin(models.Model): diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 255eddd..58bc82a 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -1,119 +1,111 @@ -from __future__ import unicode_literals - -from collections import namedtuple - -from django.db.backends.base.introspection import ( - BaseDatabaseIntrospection, FieldInfo, TableInfo, +from django.db.backends.base.introspection import TableInfo +from django.db.backends.postgresql_psycopg2.introspection import ( + DatabaseIntrospection, + FieldInfo, ) from django.utils.encoding import force_text -FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('default',)) + +def _build_table_info(row): + return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) -class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): - # Maps type codes to Django Field types. - data_types_reverse = { - 16: 'BooleanField', - 17: 'BinaryField', - 20: 'BigIntegerField', - 21: 'SmallIntegerField', - 23: 'IntegerField', - 25: 'TextField', - 700: 'FloatField', - 701: 'FloatField', - 869: 'GenericIPAddressField', - 1042: 'CharField', # blank-padded - 1043: 'CharField', - 1082: 'DateField', - 1083: 'TimeField', - 1114: 'DateTimeField', - 1184: 'DateTimeField', - 1266: 'TimeField', - 1700: 'DecimalField', - 2950: 'UUIDField', - } +def _build_field_info(col, field_map): + col_name = force_text(col[0]) - ignored_tables = [] + info_args = [col_name] + info_args.extend(col[1:6]) + # is nullable + info_args.append(field_map[col_name][0] == 'YES') + + # default value + info_args.append(field_map[col_name][1]) + + return FieldInfo(*info_args) + + +class DatabaseSchemaIntrospection(DatabaseIntrospection): _get_table_list_query = """ SELECT c.relname, c.relkind FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r', 'v') - AND n.nspname = %(schema)s; + WHERE c.relkind IN ('r', 'v', '') + AND n.nspname = %(schema)s + AND pg_catalog.pg_table_is_visible(c.oid) """ _get_table_description_query = """ SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = %(table)s - AND table_schema = %(schema)s + AND table_schema = %(schema)s """ _get_relations_query = """ - SELECT c2.relname, a1.attname, a2.attname - FROM pg_constraint con - LEFT JOIN pg_class c1 ON con.conrelid = c1.oid - LEFT JOIN pg_class c2 ON con.confrelid = c2.oid - LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] - LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] - LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = con.connamespace + SELECT c2.relname, a1.attname, a2.attname, con.conkey, con.confkey + FROM pg_catalog.pg_constraint con + LEFT JOIN pg_catalog.pg_class c1 ON con.conrelid = c1.oid + LEFT JOIN pg_catalog.pg_class c2 ON con.confrelid = c2.oid + LEFT JOIN pg_catalog.pg_attribute a1 ON c1.oid = a1.attrelid + AND a1.attnum = con.conkey[1] + LEFT JOIN pg_catalog.pg_attribute a2 ON c2.oid = a2.attrelid + AND a2.attnum = con.confkey[1] + LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = c1.connamespace WHERE c1.relname = %(table)s AND n1.nspname = %(schema)s AND con.contype = 'f' """ _get_key_columns_query = """ - SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column + SELECT kcu.column_name, ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column FROM information_schema.constraint_column_usage ccu - LEFT JOIN information_schema.key_column_usage kcu - ON ccu.constraint_catalog = kcu.constraint_catalog - AND ccu.constraint_schema = kcu.constraint_schema - AND ccu.constraint_name = kcu.constraint_name - LEFT JOIN information_schema.table_constraints tc - ON ccu.constraint_catalog = tc.constraint_catalog - AND ccu.constraint_schema = tc.constraint_schema - AND ccu.constraint_name = tc.constraint_name + LEFT JOIN information_schema.key_column_usage kcu + ON ccu.constraint_catalog = kcu.constraint_catalog + AND ccu.constraint_schema = kcu.constraint_schema + AND ccu.constraint_name = kcu.constraint_name + LEFT JOIN information_schema.table_constraints tc + ON ccu.constraint_catalog = tc.constraint_catalog + AND ccu.constraint_schema = tc.constraint_schema + AND ccu.constraint_name = tc.constraint_name WHERE kcu.table_name = %(table)s - AND kcu.table_schame = %(schema)s - AND tc.constraint_type = 'FOREIGN KEY' + AND kcu.table_schame = %(schema)s + AND tc.constraint_type = 'FOREIGN KEY' """ _get_indexes_query = """ SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, - pg_catalog.pg_index idx, pg_catalog.pg_attribute attr + pg_catalog.pg_index idx, pg_catalog.pg_attribute attr, + pg_catalog.pg_namespace n WHERE c.oid = idx.indrelid AND idx.indexrelid = c2.oid AND attr.attrelid = c.oid AND attr.attnum = idx.indkey[0] + AND c.relnamespace = n.oid AND c.relname = %(table)s AND n.nspname = %(schema)s """ _get_constraints_query = """ SELECT - c.conname, - array( - SELECT attname - FROM ( - SELECT unnest(c.conkey) AS colid, - generate_series(1, array_length(c.conkey, 1)) AS arridx - ) AS cols - JOIN pg_attribute AS ca ON cols.colid = ca.attnum - WHERE ca.attrelid = c.conrelid - ORDER BY cols.arridx - ), - c.contype, - (SELECT fkc.relname || '.' || fka.attname - FROM pg_attribute AS fka - JOIN pg_class AS fkc ON fka.attrelid = fkc.oid - WHERE fka.attrelid = c.confrelid - AND fka.attnum = c.confkey[1]) - FROM pg_constraint AS c - JOIN pg_class AS cl ON c.conrelid = cl.oid - JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid - WHERE ns.nspname = %(schema)s AND cl.relname = %(table)s + kc.constraint_name, + kc.column_name, + c.constraint_type, + array(SELECT table_name::text || '.' || column_name::text + FROM information_schema.constraint_column_usage + WHERE constraint_name = kc.constraint_name + AND table_schema = kc.table_schema) + FROM information_schema.key_column_usage AS kc + JOIN information_schema.table_constraints AS c ON + kc.table_schema = c.table_schema AND + kc.table_name = c.table_name AND + kc.constraint_name = c.constraint_name + WHERE + kc.table_schema = %(schema)s AND + kc.table_name = %(table)s + ORDER BY kc.ordinal_position ASC """ _get_check_constraints_query = """ @@ -131,63 +123,45 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): _get_index_constraints_query = """ SELECT - indexname, array_agg(attname), indisunique, indisprimary, - array_agg(ordering), amname, exprdef - FROM ( - SELECT - c2.relname as indexname, idx.*, attr.attname, am.amname, - CASE - WHEN idx.indexprs IS NOT NULL THEN - pg_get_indexdef(idx.indexrelid) - END AS exprdef, - CASE - WHEN am.amcanorder THEN - CASE (option & 1) - WHEN 1 THEN 'DESC' ELSE 'ASC' - END - END as ordering - FROM ( - SELECT - *, unnest(i.indkey) as key, unnest(i.indoption) as option - FROM pg_index i - ) idx - LEFT JOIN pg_class c ON idx.indrelid = c.oid - LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid - LEFT JOIN pg_am am ON c2.relam = am.oid - LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key - LEFT JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relname = %(table)s - AND n.nspname = %(schema)s - ) s2 - GROUP BY indexname, indisunique, indisprimary, amname, exprdef; + c2.relname, + ARRAY( + SELECT ( + SELECT attname + FROM pg_catalog.pg_attribute + WHERE attnum = i AND attrelid = c.oid + ) + FROM unnest(idx.indkey) i + ), + idx.indisunique, + idx.indisprimary + FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, + pg_catalog.pg_index idx, pg_catalog.pg_namespace n + WHERE c.oid = idx.indrelid + AND idx.indexrelid = c2.oid + AND n.oid = c.relnamespace + AND c.relname = %(table)s + AND n.nspname = %(schema)s """ - def get_field_type(self, data_type, description): - field_type = super(DatabaseSchemaIntrospection, self).get_field_type(data_type, description) - if description.default and 'nextval' in description.default: - if field_type == 'IntegerField': - return 'AutoField' - elif field_type == 'BigIntegerField': - return 'BigAutoField' - return field_type - def get_table_list(self, cursor): """ - Returns a list of table and view names in the current schema. + Returns a list of table names in the current database and schema. """ + cursor.execute(self._get_table_list_query, { - 'schema': self.connection.schema_name + 'schema': self.connection.schema_name, }) return [ - TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) + _build_table_info(row) for row in cursor.fetchall() if row[0] not in self.ignored_tables ] def get_table_description(self, cursor, table_name): """ - Returns a description of the table, with the DB-API cursor.description interface. + Returns a description of the table, with the DB-API + cursor.description interface. """ # As cursor.description does not return reliably the nullable property, @@ -196,36 +170,38 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): 'schema': self.connection.schema_name, 'table': table_name }) + field_map = {line[0]: line[1:] for line in cursor.fetchall()} - cursor.execute('SELECT * FROM %s LIMIT 1' % self.connection.ops.quote_name(table_name)) + + cursor.execute('SELECT * FROM %s.%s LIMIT 1' % ( + self.connection.schema_name, + self.connection.ops.quote_name(table_name), + )) return [ - FieldInfo(*( - (force_text(line[0]),) + - line[1:6] + - (field_map[force_text(line[0])][0] == 'YES', field_map[force_text(line[0])][1]) - )) for line in cursor.description + _build_field_info(line, field_map) + for line in cursor.description ] def get_relations(self, cursor, table_name): """ - Returns a dictionary of {field_name: (field_name_other_table, other_table)} + Returns a dictionary of + {field_name: (field_name_other_table, other_table)} representing all relationships to the given table. """ cursor.execute(self._get_relations_query, { 'schema': self.connection.schema_name, - 'table': table_name + 'table': table_name, }) relations = {} for row in cursor.fetchall(): relations[row[1]] = (row[2], row[0]) - return relations def get_key_columns(self, cursor, table_name): cursor.execute(self._get_key_columns_query, { 'schema': self.connection.schema_name, - 'table': table_name + 'table': table_name, }) return list(cursor.fetchall()) @@ -236,17 +212,20 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): 'schema': self.connection.schema_name, 'table': table_name, }) + indexes = {} for row in cursor.fetchall(): - # row[1] (idx.indkey) is stored in the DB as an array. It comes out as - # a string of space-separated integers. This designates the field - # indexes (1-based) of the fields that have indexes on the table. - # Here, we skip any indexes across multiple fields. + # row[1] (idx.indkey) is stored in the DB as an array. + # It comes out as a string of space-separated integers. + # This designates the field indexes (1-based) of the fields + # that have indexes on the table. Here, we skip any indexes + # across multiple fields. if ' ' in row[1]: continue if row[0] not in indexes: indexes[row[0]] = {'primary_key': False, 'unique': False} - # It's possible to have the unique and PK constraints in separate indexes. + # It's possible to have the unique and PK constraints + # in separate indexes. if row[3]: indexes[row[0]]['primary_key'] = True if row[2]: @@ -255,31 +234,54 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): def get_constraints(self, cursor, table_name): """ - Retrieves any constraints or keys (unique, pk, fk, check, index) across - one or more columns. Also retrieve the definition of expression-based - indexes. + Retrieves any constraints or keys (unique, pk, fk, check, index) + across one or more columns. """ constraints = {} - # Loop over the key table, collecting things as constraints. The column - # array must return column names in the same order in which they were - # created - # The subquery containing generate_series can be replaced with - # "WITH ORDINALITY" when support for PostgreSQL 9.3 is dropped. + + # Loop over the key table, collecting things as constraints + # This will get PKs, FKs, and uniques, but not CHECK cursor.execute(self._get_constraints_query, { 'schema': self.connection.schema_name, 'table': table_name, }) - for constraint, columns, kind, used_cols in cursor.fetchall(): - constraints[constraint] = { - "columns": columns, - "primary_key": kind == "p", - "unique": kind in ["p", "u"], - "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, - "check": kind == "c", - "index": False, - "definition": None, - } + for constraint, column, kind, used_cols in cursor.fetchall(): + # If we're the first column, make the record + if constraint not in constraints: + constraints[constraint] = { + "columns": [], + "primary_key": kind.lower() == "primary key", + "unique": kind.lower() in ["primary key", "unique"], + "foreign_key": + tuple(used_cols[0].split(".", 1)) + if kind.lower() == "foreign key" + else None, + "check": False, + "index": False, + } + # Record the details + constraints[constraint]['columns'].append(column) + + # Now get CHECK constraint columns + cursor.execute(self._get_check_constraints_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + for constraint, column in cursor.fetchall(): + # If we're the first column, make the record + if constraint not in constraints: + constraints[constraint] = { + "columns": [], + "primary_key": False, + "unique": False, + "foreign_key": None, + "check": True, + "index": False, + } + # Record the details + constraints[constraint]['columns'].append(column) # Now get indexes cursor.execute(self._get_index_constraints_query, { @@ -287,17 +289,15 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): 'table': table_name, }) - for index, columns, unique, primary, orders, type_, definition in cursor.fetchall(): + for index, columns, unique, primary in cursor.fetchall(): if index not in constraints: constraints[index] = { - "columns": columns if columns != [None] else [], - "orders": orders if orders != [None] else [], + "columns": list(columns), "primary_key": primary, "unique": unique, "foreign_key": None, "check": False, "index": True, - "type": type_, - "definition": definition, } + return constraints From dc442f41aa1ded37ddf7ec0ba719f490d2cef60a Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Thu, 29 Sep 2016 23:42:32 +0200 Subject: [PATCH 044/157] Added test for auto dropping schema when tenant deleted. Closes #378. --- tenant_schemas/tests/models.py | 7 +++++++ tenant_schemas/tests/test_tenants.py | 22 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/tests/models.py b/tenant_schemas/tests/models.py index da1ba7b..3cb0f70 100644 --- a/tenant_schemas/tests/models.py +++ b/tenant_schemas/tests/models.py @@ -15,3 +15,10 @@ class NonAutoSyncTenant(TenantMixin): class Meta: app_label = 'tenant_schemas' + + +class AutoDropTenant(TenantMixin): + auto_drop_schema = True + + class Meta: + app_label = 'tenant_schemas' diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 4ae763a..af4bc4c 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -8,7 +8,7 @@ from django.db import connection from dts_test_app.models import DummyModel, ModelWithFkToPublicUser from tenant_schemas.test.cases import TenantTestCase -from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant +from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant, AutoDropTenant from tenant_schemas.tests.testcases import BaseTestCase from tenant_schemas.utils import tenant_context, schema_context, schema_exists, get_tenant_model, get_public_schema_name @@ -47,7 +47,7 @@ class TenantDataAndSettingsTest(BaseTestCase): self.assertFalse(schema_exists('non_auto_sync_tenant')) tenant = NonAutoSyncTenant(domain_url='something.test.com', - schema_name='test') + schema_name='non_auto_sync_tenant') tenant.save(verbosity=BaseTestCase.get_verbosity()) self.assertFalse(schema_exists(tenant.schema_name)) @@ -76,6 +76,24 @@ class TenantDataAndSettingsTest(BaseTestCase): # test if data is still there self.assertEquals(DummyModel.objects.count(), 2) + def test_auto_drop_schema(self): + """ + When deleting a tenant with auto_drop_schema=True, it should delete + the schema associated with the tenant. + """ + self.assertFalse(schema_exists('auto_drop_tenant')) + tenant = AutoDropTenant(domain_url='something.test.com', + schema_name='auto_drop_tenant') + tenant.save(verbosity=BaseTestCase.get_verbosity()) + self.assertTrue(schema_exists(tenant.schema_name)) + cursor = connection.cursor() + + # Force pending trigger events to be executed + cursor.execute('SET CONSTRAINTS ALL IMMEDIATE') + + tenant.delete() + self.assertFalse(schema_exists(tenant.schema_name)) + def test_switching_search_path(self): tenant1 = Tenant(domain_url='something.test.com', schema_name='tenant1') From 84bd6d2ce81655eebbe1720ff63ce764be1ed49d Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Thu, 29 Sep 2016 23:58:49 +0200 Subject: [PATCH 045/157] Revert "Revert "Add UUIDField, some index introspections and fix migrations performance issue"" --- tenant_schemas/models.py | 7 +- .../postgresql_backend/introspection.py | 306 +++++++++--------- 2 files changed, 155 insertions(+), 158 deletions(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 139cf3a..7907ac7 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -1,12 +1,9 @@ -import django -from django.conf import settings -from django.db import models, connection from django.core.management import call_command +from django.db import connection, models from tenant_schemas.postgresql_backend.base import _check_schema_name from tenant_schemas.signals import post_schema_sync -from tenant_schemas.utils import django_is_in_test_mode, schema_exists -from tenant_schemas.utils import get_public_schema_name +from tenant_schemas.utils import get_public_schema_name, schema_exists class TenantMixin(models.Model): diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 58bc82a..255eddd 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -1,111 +1,119 @@ -from django.db.backends.base.introspection import TableInfo -from django.db.backends.postgresql_psycopg2.introspection import ( - DatabaseIntrospection, - FieldInfo, +from __future__ import unicode_literals + +from collections import namedtuple + +from django.db.backends.base.introspection import ( + BaseDatabaseIntrospection, FieldInfo, TableInfo, ) from django.utils.encoding import force_text - -def _build_table_info(row): - return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) +FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('default',)) -def _build_field_info(col, field_map): - col_name = force_text(col[0]) +class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): + # Maps type codes to Django Field types. + data_types_reverse = { + 16: 'BooleanField', + 17: 'BinaryField', + 20: 'BigIntegerField', + 21: 'SmallIntegerField', + 23: 'IntegerField', + 25: 'TextField', + 700: 'FloatField', + 701: 'FloatField', + 869: 'GenericIPAddressField', + 1042: 'CharField', # blank-padded + 1043: 'CharField', + 1082: 'DateField', + 1083: 'TimeField', + 1114: 'DateTimeField', + 1184: 'DateTimeField', + 1266: 'TimeField', + 1700: 'DecimalField', + 2950: 'UUIDField', + } - info_args = [col_name] - info_args.extend(col[1:6]) + ignored_tables = [] - # is nullable - info_args.append(field_map[col_name][0] == 'YES') - - # default value - info_args.append(field_map[col_name][1]) - - return FieldInfo(*info_args) - - -class DatabaseSchemaIntrospection(DatabaseIntrospection): _get_table_list_query = """ SELECT c.relname, c.relkind FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind IN ('r', 'v', '') - AND n.nspname = %(schema)s - AND pg_catalog.pg_table_is_visible(c.oid) + WHERE c.relkind IN ('r', 'v') + AND n.nspname = %(schema)s; """ _get_table_description_query = """ SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = %(table)s - AND table_schema = %(schema)s + AND table_schema = %(schema)s """ _get_relations_query = """ - SELECT c2.relname, a1.attname, a2.attname, con.conkey, con.confkey - FROM pg_catalog.pg_constraint con - LEFT JOIN pg_catalog.pg_class c1 ON con.conrelid = c1.oid - LEFT JOIN pg_catalog.pg_class c2 ON con.confrelid = c2.oid - LEFT JOIN pg_catalog.pg_attribute a1 ON c1.oid = a1.attrelid - AND a1.attnum = con.conkey[1] - LEFT JOIN pg_catalog.pg_attribute a2 ON c2.oid = a2.attrelid - AND a2.attnum = con.confkey[1] - LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = c1.connamespace + SELECT c2.relname, a1.attname, a2.attname + FROM pg_constraint con + LEFT JOIN pg_class c1 ON con.conrelid = c1.oid + LEFT JOIN pg_class c2 ON con.confrelid = c2.oid + LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] + LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] + LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = con.connamespace WHERE c1.relname = %(table)s AND n1.nspname = %(schema)s AND con.contype = 'f' """ _get_key_columns_query = """ - SELECT kcu.column_name, ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column + SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column FROM information_schema.constraint_column_usage ccu - LEFT JOIN information_schema.key_column_usage kcu - ON ccu.constraint_catalog = kcu.constraint_catalog - AND ccu.constraint_schema = kcu.constraint_schema - AND ccu.constraint_name = kcu.constraint_name - LEFT JOIN information_schema.table_constraints tc - ON ccu.constraint_catalog = tc.constraint_catalog - AND ccu.constraint_schema = tc.constraint_schema - AND ccu.constraint_name = tc.constraint_name + LEFT JOIN information_schema.key_column_usage kcu + ON ccu.constraint_catalog = kcu.constraint_catalog + AND ccu.constraint_schema = kcu.constraint_schema + AND ccu.constraint_name = kcu.constraint_name + LEFT JOIN information_schema.table_constraints tc + ON ccu.constraint_catalog = tc.constraint_catalog + AND ccu.constraint_schema = tc.constraint_schema + AND ccu.constraint_name = tc.constraint_name WHERE kcu.table_name = %(table)s - AND kcu.table_schame = %(schema)s - AND tc.constraint_type = 'FOREIGN KEY' + AND kcu.table_schame = %(schema)s + AND tc.constraint_type = 'FOREIGN KEY' """ _get_indexes_query = """ SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, - pg_catalog.pg_index idx, pg_catalog.pg_attribute attr, - pg_catalog.pg_namespace n + pg_catalog.pg_index idx, pg_catalog.pg_attribute attr WHERE c.oid = idx.indrelid AND idx.indexrelid = c2.oid AND attr.attrelid = c.oid AND attr.attnum = idx.indkey[0] - AND c.relnamespace = n.oid AND c.relname = %(table)s AND n.nspname = %(schema)s """ _get_constraints_query = """ SELECT - kc.constraint_name, - kc.column_name, - c.constraint_type, - array(SELECT table_name::text || '.' || column_name::text - FROM information_schema.constraint_column_usage - WHERE constraint_name = kc.constraint_name - AND table_schema = kc.table_schema) - FROM information_schema.key_column_usage AS kc - JOIN information_schema.table_constraints AS c ON - kc.table_schema = c.table_schema AND - kc.table_name = c.table_name AND - kc.constraint_name = c.constraint_name - WHERE - kc.table_schema = %(schema)s AND - kc.table_name = %(table)s - ORDER BY kc.ordinal_position ASC + c.conname, + array( + SELECT attname + FROM ( + SELECT unnest(c.conkey) AS colid, + generate_series(1, array_length(c.conkey, 1)) AS arridx + ) AS cols + JOIN pg_attribute AS ca ON cols.colid = ca.attnum + WHERE ca.attrelid = c.conrelid + ORDER BY cols.arridx + ), + c.contype, + (SELECT fkc.relname || '.' || fka.attname + FROM pg_attribute AS fka + JOIN pg_class AS fkc ON fka.attrelid = fkc.oid + WHERE fka.attrelid = c.confrelid + AND fka.attnum = c.confkey[1]) + FROM pg_constraint AS c + JOIN pg_class AS cl ON c.conrelid = cl.oid + JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid + WHERE ns.nspname = %(schema)s AND cl.relname = %(table)s """ _get_check_constraints_query = """ @@ -123,45 +131,63 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): _get_index_constraints_query = """ SELECT - c2.relname, - ARRAY( - SELECT ( - SELECT attname - FROM pg_catalog.pg_attribute - WHERE attnum = i AND attrelid = c.oid - ) - FROM unnest(idx.indkey) i - ), - idx.indisunique, - idx.indisprimary - FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, - pg_catalog.pg_index idx, pg_catalog.pg_namespace n - WHERE c.oid = idx.indrelid - AND idx.indexrelid = c2.oid - AND n.oid = c.relnamespace - AND c.relname = %(table)s - AND n.nspname = %(schema)s + indexname, array_agg(attname), indisunique, indisprimary, + array_agg(ordering), amname, exprdef + FROM ( + SELECT + c2.relname as indexname, idx.*, attr.attname, am.amname, + CASE + WHEN idx.indexprs IS NOT NULL THEN + pg_get_indexdef(idx.indexrelid) + END AS exprdef, + CASE + WHEN am.amcanorder THEN + CASE (option & 1) + WHEN 1 THEN 'DESC' ELSE 'ASC' + END + END as ordering + FROM ( + SELECT + *, unnest(i.indkey) as key, unnest(i.indoption) as option + FROM pg_index i + ) idx + LEFT JOIN pg_class c ON idx.indrelid = c.oid + LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid + LEFT JOIN pg_am am ON c2.relam = am.oid + LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key + LEFT JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.relname = %(table)s + AND n.nspname = %(schema)s + ) s2 + GROUP BY indexname, indisunique, indisprimary, amname, exprdef; """ + def get_field_type(self, data_type, description): + field_type = super(DatabaseSchemaIntrospection, self).get_field_type(data_type, description) + if description.default and 'nextval' in description.default: + if field_type == 'IntegerField': + return 'AutoField' + elif field_type == 'BigIntegerField': + return 'BigAutoField' + return field_type + def get_table_list(self, cursor): """ - Returns a list of table names in the current database and schema. + Returns a list of table and view names in the current schema. """ - cursor.execute(self._get_table_list_query, { - 'schema': self.connection.schema_name, + 'schema': self.connection.schema_name }) return [ - _build_table_info(row) + TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) for row in cursor.fetchall() if row[0] not in self.ignored_tables ] def get_table_description(self, cursor, table_name): """ - Returns a description of the table, with the DB-API - cursor.description interface. + Returns a description of the table, with the DB-API cursor.description interface. """ # As cursor.description does not return reliably the nullable property, @@ -170,38 +196,36 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 'schema': self.connection.schema_name, 'table': table_name }) - field_map = {line[0]: line[1:] for line in cursor.fetchall()} - - cursor.execute('SELECT * FROM %s.%s LIMIT 1' % ( - self.connection.schema_name, - self.connection.ops.quote_name(table_name), - )) + cursor.execute('SELECT * FROM %s LIMIT 1' % self.connection.ops.quote_name(table_name)) return [ - _build_field_info(line, field_map) - for line in cursor.description + FieldInfo(*( + (force_text(line[0]),) + + line[1:6] + + (field_map[force_text(line[0])][0] == 'YES', field_map[force_text(line[0])][1]) + )) for line in cursor.description ] def get_relations(self, cursor, table_name): """ - Returns a dictionary of - {field_name: (field_name_other_table, other_table)} + Returns a dictionary of {field_name: (field_name_other_table, other_table)} representing all relationships to the given table. """ cursor.execute(self._get_relations_query, { 'schema': self.connection.schema_name, - 'table': table_name, + 'table': table_name }) relations = {} for row in cursor.fetchall(): relations[row[1]] = (row[2], row[0]) + return relations def get_key_columns(self, cursor, table_name): cursor.execute(self._get_key_columns_query, { 'schema': self.connection.schema_name, - 'table': table_name, + 'table': table_name }) return list(cursor.fetchall()) @@ -212,20 +236,17 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 'schema': self.connection.schema_name, 'table': table_name, }) - indexes = {} for row in cursor.fetchall(): - # row[1] (idx.indkey) is stored in the DB as an array. - # It comes out as a string of space-separated integers. - # This designates the field indexes (1-based) of the fields - # that have indexes on the table. Here, we skip any indexes - # across multiple fields. + # row[1] (idx.indkey) is stored in the DB as an array. It comes out as + # a string of space-separated integers. This designates the field + # indexes (1-based) of the fields that have indexes on the table. + # Here, we skip any indexes across multiple fields. if ' ' in row[1]: continue if row[0] not in indexes: indexes[row[0]] = {'primary_key': False, 'unique': False} - # It's possible to have the unique and PK constraints - # in separate indexes. + # It's possible to have the unique and PK constraints in separate indexes. if row[3]: indexes[row[0]]['primary_key'] = True if row[2]: @@ -234,54 +255,31 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): def get_constraints(self, cursor, table_name): """ - Retrieves any constraints or keys (unique, pk, fk, check, index) - across one or more columns. + Retrieves any constraints or keys (unique, pk, fk, check, index) across + one or more columns. Also retrieve the definition of expression-based + indexes. """ constraints = {} - - # Loop over the key table, collecting things as constraints - # This will get PKs, FKs, and uniques, but not CHECK + # Loop over the key table, collecting things as constraints. The column + # array must return column names in the same order in which they were + # created + # The subquery containing generate_series can be replaced with + # "WITH ORDINALITY" when support for PostgreSQL 9.3 is dropped. cursor.execute(self._get_constraints_query, { 'schema': self.connection.schema_name, 'table': table_name, }) - for constraint, column, kind, used_cols in cursor.fetchall(): - # If we're the first column, make the record - if constraint not in constraints: - constraints[constraint] = { - "columns": [], - "primary_key": kind.lower() == "primary key", - "unique": kind.lower() in ["primary key", "unique"], - "foreign_key": - tuple(used_cols[0].split(".", 1)) - if kind.lower() == "foreign key" - else None, - "check": False, - "index": False, - } - # Record the details - constraints[constraint]['columns'].append(column) - - # Now get CHECK constraint columns - cursor.execute(self._get_check_constraints_query, { - 'schema': self.connection.schema_name, - 'table': table_name, - }) - - for constraint, column in cursor.fetchall(): - # If we're the first column, make the record - if constraint not in constraints: - constraints[constraint] = { - "columns": [], - "primary_key": False, - "unique": False, - "foreign_key": None, - "check": True, - "index": False, - } - # Record the details - constraints[constraint]['columns'].append(column) + for constraint, columns, kind, used_cols in cursor.fetchall(): + constraints[constraint] = { + "columns": columns, + "primary_key": kind == "p", + "unique": kind in ["p", "u"], + "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, + "check": kind == "c", + "index": False, + "definition": None, + } # Now get indexes cursor.execute(self._get_index_constraints_query, { @@ -289,15 +287,17 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 'table': table_name, }) - for index, columns, unique, primary in cursor.fetchall(): + for index, columns, unique, primary, orders, type_, definition in cursor.fetchall(): if index not in constraints: constraints[index] = { - "columns": list(columns), + "columns": columns if columns != [None] else [], + "orders": orders if orders != [None] else [], "primary_key": primary, "unique": unique, "foreign_key": None, "check": False, "index": True, + "type": type_, + "definition": definition, } - return constraints From 366cbe32646af207ff930e456c2a283dd4976516 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Fri, 30 Sep 2016 00:08:40 +0200 Subject: [PATCH 046/157] Fixed auto drop schema test --- tenant_schemas/tests/models.py | 7 ------- tenant_schemas/tests/test_tenants.py | 10 +++++----- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/tenant_schemas/tests/models.py b/tenant_schemas/tests/models.py index 3cb0f70..da1ba7b 100644 --- a/tenant_schemas/tests/models.py +++ b/tenant_schemas/tests/models.py @@ -15,10 +15,3 @@ class NonAutoSyncTenant(TenantMixin): class Meta: app_label = 'tenant_schemas' - - -class AutoDropTenant(TenantMixin): - auto_drop_schema = True - - class Meta: - app_label = 'tenant_schemas' diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index af4bc4c..b4570cd 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -8,7 +8,7 @@ from django.db import connection from dts_test_app.models import DummyModel, ModelWithFkToPublicUser from tenant_schemas.test.cases import TenantTestCase -from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant, AutoDropTenant +from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant from tenant_schemas.tests.testcases import BaseTestCase from tenant_schemas.utils import tenant_context, schema_context, schema_exists, get_tenant_model, get_public_schema_name @@ -44,12 +44,10 @@ class TenantDataAndSettingsTest(BaseTestCase): When saving a tenant that has the flag auto_create_schema as False, the schema should not be created when saving the tenant. """ - self.assertFalse(schema_exists('non_auto_sync_tenant')) - + self.assertFalse(schema_exists('non_auto_sync_tenant')) tenant = NonAutoSyncTenant(domain_url='something.test.com', schema_name='non_auto_sync_tenant') tenant.save(verbosity=BaseTestCase.get_verbosity()) - self.assertFalse(schema_exists(tenant.schema_name)) def test_sync_tenant(self): @@ -82,7 +80,8 @@ class TenantDataAndSettingsTest(BaseTestCase): the schema associated with the tenant. """ self.assertFalse(schema_exists('auto_drop_tenant')) - tenant = AutoDropTenant(domain_url='something.test.com', + Tenant.auto_drop_schema = True + tenant = Tenant(domain_url='something.test.com', schema_name='auto_drop_tenant') tenant.save(verbosity=BaseTestCase.get_verbosity()) self.assertTrue(schema_exists(tenant.schema_name)) @@ -93,6 +92,7 @@ class TenantDataAndSettingsTest(BaseTestCase): tenant.delete() self.assertFalse(schema_exists(tenant.schema_name)) + Tenant.auto_drop_schema = False def test_switching_search_path(self): tenant1 = Tenant(domain_url='something.test.com', From cc437ed8305ec1b88c10508d779972e4c05163cb Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sat, 1 Oct 2016 18:42:24 +0200 Subject: [PATCH 047/157] Remove line that says that contenttypes must be in TENANT_APPS. Fixes #369. --- docs/install.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index 528af61..eb9c19a 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -96,7 +96,6 @@ To make use of shared and tenant-specific applications, there are two settings c ) TENANT_APPS = ( - # The following Django contrib apps must be in TENANT_APPS 'django.contrib.contenttypes', # your tenant-specific apps From 0a9583e954e42d7b45e0af2cf6ac324775b1c44c Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sat, 1 Oct 2016 18:44:31 +0200 Subject: [PATCH 048/157] Now listing the correct context processor in the docs. Fixes #367. --- docs/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index eb9c19a..9ffd82f 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -42,7 +42,7 @@ If the hostname in the request does not match a valid tenant ``domain_url``, a H #... ) -Make sure you have ``django.core.context_processors.request`` listed under ``TEMPLATE_CONTEXT_PROCESSORS`` else the tenant will not be available on ``request``. +Make sure you have ``django.template.context_processors.request`` (``django.core.context_processors.request`` if you're on Django 1.8) listed under ``TEMPLATE_CONTEXT_PROCESSORS`` else the tenant will not be available on ``request``. .. code-block:: python From e9d90b6cd70bf11f16fa15b7101ca9b2d360bdcd Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Mon, 3 Oct 2016 10:24:09 -0300 Subject: [PATCH 049/157] adding FastTenantTestCase and it's documentation --- docs/test.rst | 5 +++++ tenant_schemas/test/cases.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/test.rst b/docs/test.rst index da6bf95..e25bb3a 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -32,4 +32,9 @@ Because django will not create tenants for you during your tests, we have packed response = self.c.get(reverse('user_profile')) self.assertEqual(response.status_code, 200) + +Running tests faster +-------------------- +Using the ``TenantTestCase`` can make running your tests really slow quite early in your project. This is due to the fact that it drops, recreates the test schema and runs migrations for every ``TenantTestCase`` you have. If you want to gain speed, there's a ``FastTenantTestCase`` where the test schema will be created and migrations ran only one time. The gain in speed is noticiable but be aware that by using this you will be perpertraiting state between your test cases, please make sure your they wont be affected by this. + .. _tox: https://tox.readthedocs.io/ diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index 528a7f1..b1c70fc 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -30,3 +30,25 @@ class TenantTestCase(TestCase): schema_name=get_public_schema_name(), interactive=False, verbosity=0) + + +class FastTenantTestCase(TenantTestCase): + + @classmethod + def setUpClass(cls): + cls.sync_shared() + tenant_domain = 'tenant.test.com' + + TenantModel = get_tenant_model() + + try: + cls.tenant = TenantModel.objects.get(domain_url=tenant_domain, schema_name='test') + except: + cls.tenant = TenantModel(domain_url=tenant_domain, schema_name='test') + cls.tenant.save(verbosity=0) + + connection.set_tenant(cls.tenant) + + @classmethod + def tearDownClass(cls): + connection.set_schema_to_public() From a3b23d5f3ad30c045ff3dce024402cf8d7d354c3 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Mon, 3 Oct 2016 10:27:15 -0300 Subject: [PATCH 050/157] adding example on how to import FastTenantTestCase --- docs/test.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/test.rst b/docs/test.rst index e25bb3a..841eef3 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -37,4 +37,9 @@ Running tests faster -------------------- Using the ``TenantTestCase`` can make running your tests really slow quite early in your project. This is due to the fact that it drops, recreates the test schema and runs migrations for every ``TenantTestCase`` you have. If you want to gain speed, there's a ``FastTenantTestCase`` where the test schema will be created and migrations ran only one time. The gain in speed is noticiable but be aware that by using this you will be perpertraiting state between your test cases, please make sure your they wont be affected by this. +.. code-block:: python + + from tenant_schemas.test.cases import FastTenantTestCase + + .. _tox: https://tox.readthedocs.io/ From fb2ec81d0f4bb3095d8d34e2438bc4fa1665aab2 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Mon, 3 Oct 2016 11:22:06 -0300 Subject: [PATCH 051/157] documenting 'utils' module --- docs/index.rst | 1 + docs/utils.rst | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docs/utils.rst diff --git a/docs/index.rst b/docs/index.rst index 5d634d9..8f04e00 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,6 +53,7 @@ Contents use examples templates + utils test involved diff --git a/docs/utils.rst b/docs/utils.rst new file mode 100644 index 0000000..ba45730 --- /dev/null +++ b/docs/utils.rst @@ -0,0 +1,46 @@ +===== +Utils +===== + +.. function:: schema_context(schema_name) + +This is a context manager. Database queries performed inside it will be executed in against the passed ``schema_name``. + +.. code-block:: python + + from tenant_schemas.utils import schema_context + from django.auth.models import User + + with schema_context('some_tenant'): + users = User.objects.all() + print(users) + + +.. function:: tenant_context(tenant) + +Same as ``schema_context`` but this time a tenant must be passed. + +.. code-block:: python + + from tenant_schemas.utils import tenant_context, get_tenant_model + from django.auth.models import User + + tenant = get_tenant_model().objects.get(schema_name='some_tenant') + + with tenant_context(tenant): + users = User.objects.all() + print(users) + + +.. function:: get_tenant_model() + +Returns the tenant model. + +.. code-block:: python + + from tenant_schemas.utils import get_tenant_model + + tenant = get_tenant_model().objects.get(schema_name='some_tenant') + print(tenant.domain_url) + + From a4ad7980b93b85f918bbdb7c6a067252b625bdd9 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 3 Oct 2016 22:38:46 +0200 Subject: [PATCH 052/157] Revert "Documenting 'utils' module" --- docs/index.rst | 1 - docs/utils.rst | 46 ---------------------------------------------- 2 files changed, 47 deletions(-) delete mode 100644 docs/utils.rst diff --git a/docs/index.rst b/docs/index.rst index 8f04e00..5d634d9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,7 +53,6 @@ Contents use examples templates - utils test involved diff --git a/docs/utils.rst b/docs/utils.rst deleted file mode 100644 index ba45730..0000000 --- a/docs/utils.rst +++ /dev/null @@ -1,46 +0,0 @@ -===== -Utils -===== - -.. function:: schema_context(schema_name) - -This is a context manager. Database queries performed inside it will be executed in against the passed ``schema_name``. - -.. code-block:: python - - from tenant_schemas.utils import schema_context - from django.auth.models import User - - with schema_context('some_tenant'): - users = User.objects.all() - print(users) - - -.. function:: tenant_context(tenant) - -Same as ``schema_context`` but this time a tenant must be passed. - -.. code-block:: python - - from tenant_schemas.utils import tenant_context, get_tenant_model - from django.auth.models import User - - tenant = get_tenant_model().objects.get(schema_name='some_tenant') - - with tenant_context(tenant): - users = User.objects.all() - print(users) - - -.. function:: get_tenant_model() - -Returns the tenant model. - -.. code-block:: python - - from tenant_schemas.utils import get_tenant_model - - tenant = get_tenant_model().objects.get(schema_name='some_tenant') - print(tenant.domain_url) - - From 805d58a8fb0b551ab4dcc1be1b4755cc6cabb0ad Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 3 Oct 2016 22:44:00 +0200 Subject: [PATCH 053/157] Updated utils docs --- docs/use.rst | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/docs/use.rst b/docs/use.rst index 9a6c611..4e638f2 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -133,46 +133,39 @@ Utils There are several utils available in `tenant_schemas.utils` that can help you in writing more complicated applications. -schema_context -~~~~~~~~~~~~~~ +.. function:: schema_context(schema_name) -This context manager activates a specific schema name. +This is a context manager. Database queries performed inside it will be executed in against the passed ``schema_name``. .. code-block:: python from tenant_schemas.utils import schema_context - # runs in a schema - with schema_context(schema_name): - # this will run in schema `schema_name` + # All comands here are ran under the schema `schema_name` - # runs back in the original schema + # Restores the `SEARCH_PATH` to its original value -tenant_context -~~~~~~~~~~~~~~ +.. function:: tenant_context(tenant_object) -This context manager is very similiar to the ``schema_context`` manager, -but it takes a whole tenant model object as the argument. +This context manager is very similiar to the ``schema_context`` function, +but it takes a tenant model object as the argument instead. .. code-block:: python from tenant_schemas.utils import tenant_context - # runs in a schema - with tenant_context(tenant): - # this will run in schema of the tenant `tenant` + # All commands here are ran under the schema from the `tenant` object - # runs back in the original schema + # Restores the `SEARCH_PATH` to its original value -schema_exists -~~~~~~~~~~~~~ +.. function:: schema_exists(schema_name) -Returns True if a schema exists in the current database. +Returns ``True`` if a schema exists in the current database. .. code-block:: python @@ -193,21 +186,19 @@ Returns True if a schema exists in the current database. return schema_name -get_tenant_model -~~~~~~~~~~~~~~~~ +.. function:: get_tenant_model() Returns the class of the tenant model. -get_public_schema_name -~~~~~~~~~~~~~~~~~~~~~~ +.. function:: get_public_schema_name() -Returns the name of the public schema (from settings or the default). +Returns the name of the public schema (from settings or the default ``public``). -get_limit_set_calls +.. function:: get_limit_set_calls() ~~~~~~~~~~~~~~~~~~~ -Returns the TENANT_LIMIT_SET_CALLS setting or the default (False). See bellow. +Returns the ``TENANT_LIMIT_SET_CALLS`` setting or the default (``False``). See below. Logging From 906160f357363db98cc58424547cf1f65bee4ff4 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 3 Oct 2016 22:44:36 +0200 Subject: [PATCH 054/157] Update use.rst --- docs/use.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/use.rst b/docs/use.rst index 4e638f2..595f74d 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -196,7 +196,6 @@ Returns the name of the public schema (from settings or the default ``public``). .. function:: get_limit_set_calls() -~~~~~~~~~~~~~~~~~~~ Returns the ``TENANT_LIMIT_SET_CALLS`` setting or the default (``False``). See below. From 016e2682dd806e98f7a4287efce2652a61def716 Mon Sep 17 00:00:00 2001 From: Filipe Ximenes Date: Tue, 4 Oct 2016 17:34:46 -0300 Subject: [PATCH 055/157] improving documentation texto for FastTenantTestCase --- docs/test.rst | 2 ++ tenant_schemas/test/cases.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/test.rst b/docs/test.rst index 841eef3..459b0c4 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -37,6 +37,8 @@ Running tests faster -------------------- Using the ``TenantTestCase`` can make running your tests really slow quite early in your project. This is due to the fact that it drops, recreates the test schema and runs migrations for every ``TenantTestCase`` you have. If you want to gain speed, there's a ``FastTenantTestCase`` where the test schema will be created and migrations ran only one time. The gain in speed is noticiable but be aware that by using this you will be perpertraiting state between your test cases, please make sure your they wont be affected by this. +Running tests using ``TenantTestCase`` can start being a bottleneck once the number of tests grow. If you do not care that the state between tests is kept, an alternative is to use the class ``FastTenantTestCase``. Unlike ``TenantTestCase``, the test schema and its migrations will only be created and ran once. This is a significant improvement in speed coming at the cost of shared state. + .. code-block:: python from tenant_schemas.test.cases import FastTenantTestCase diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index b1c70fc..a6b2e29 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -40,7 +40,6 @@ class FastTenantTestCase(TenantTestCase): tenant_domain = 'tenant.test.com' TenantModel = get_tenant_model() - try: cls.tenant = TenantModel.objects.get(domain_url=tenant_domain, schema_name='test') except: From 4fb28368f6cdce28c10070239147425edcb124ed Mon Sep 17 00:00:00 2001 From: anye Date: Fri, 7 Oct 2016 12:49:44 -0500 Subject: [PATCH 056/157] fixed error that was causing createsuperuser to fail for later versions of django --- tenant_schemas/management/commands/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 3dae6d7..e4b9b1d 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -1,3 +1,4 @@ +import django from django.conf import settings from django.core.management import call_command, get_commands, load_command_class from django.core.management.base import BaseCommand, CommandError @@ -120,7 +121,9 @@ class TenantWrappedCommand(InteractiveTenantOption, BaseCommand): def __new__(cls, *args, **kwargs): obj = super(TenantWrappedCommand, cls).__new__(cls, *args, **kwargs) obj.command_instance = obj.COMMAND() - obj.option_list = obj.command_instance.option_list + # This fails for later versions of django + if django.VERSION <= (1,10,0): + obj.option_list = obj.command_instance.option_list return obj def add_arguments(self, parser): From 8774cd499ecd7b053924522c069caff737de57a2 Mon Sep 17 00:00:00 2001 From: anye Date: Fri, 7 Oct 2016 14:28:55 -0500 Subject: [PATCH 057/157] removed comment per request --- tenant_schemas/management/commands/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index e4b9b1d..54c4711 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -121,7 +121,6 @@ class TenantWrappedCommand(InteractiveTenantOption, BaseCommand): def __new__(cls, *args, **kwargs): obj = super(TenantWrappedCommand, cls).__new__(cls, *args, **kwargs) obj.command_instance = obj.COMMAND() - # This fails for later versions of django if django.VERSION <= (1,10,0): obj.option_list = obj.command_instance.option_list return obj From 529361a85fe9a8f8508fc8e1e6f24442a3df165a Mon Sep 17 00:00:00 2001 From: Farooq Azam Date: Fri, 21 Oct 2016 16:26:02 +0500 Subject: [PATCH 058/157] Removed quotes from username value for createsuperuser command --- docs/use.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/use.rst b/docs/use.rst index 595f74d..aea9028 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -111,7 +111,7 @@ The command ``createsuperuser`` is already automatically wrapped to have a ``sch .. code-block:: bash - ./manage.py createsuperuser --username='admin' --schema=customer1 + ./manage.py createsuperuser --username=admin --schema=customer1 list_tenants From 6c2533cd544060ac98a0b20b3d8dc1eabc4aa6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20S=C3=A1nchez?= Date: Tue, 8 Nov 2016 23:21:35 -0300 Subject: [PATCH 059/157] Modify tests to run and pass with python 3 --- tenant_schemas/tests/__init__.py | 10 +++++----- tenant_schemas/tests/test_tenants.py | 13 +++++++++---- tox.ini | 3 ++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tenant_schemas/tests/__init__.py b/tenant_schemas/tests/__init__.py index b5259a7..09a3f81 100644 --- a/tenant_schemas/tests/__init__.py +++ b/tenant_schemas/tests/__init__.py @@ -1,5 +1,5 @@ -from test_routes import * -from test_tenants import * -from test_cache import * -from test_log import * -from test_utils import * +from .test_routes import * +from .test_tenants import * +from .test_cache import * +from .test_log import * +from .test_utils import * diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index b4570cd..c6b222b 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -1,5 +1,10 @@ import json -from StringIO import StringIO +try: + # python 2 + from StringIO import StringIO +except ImportError: + # python 3 + from io import StringIO from django.conf import settings from django.contrib.auth.models import User @@ -44,7 +49,7 @@ class TenantDataAndSettingsTest(BaseTestCase): When saving a tenant that has the flag auto_create_schema as False, the schema should not be created when saving the tenant. """ - self.assertFalse(schema_exists('non_auto_sync_tenant')) + self.assertFalse(schema_exists('non_auto_sync_tenant')) tenant = NonAutoSyncTenant(domain_url='something.test.com', schema_name='non_auto_sync_tenant') tenant.save(verbosity=BaseTestCase.get_verbosity()) @@ -72,7 +77,7 @@ class TenantDataAndSettingsTest(BaseTestCase): connection.set_tenant(tenant) # test if data is still there - self.assertEquals(DummyModel.objects.count(), 2) + self.assertEqual(DummyModel.objects.count(), 2) def test_auto_drop_schema(self): """ @@ -240,7 +245,7 @@ class TenantCommandTest(BaseTestCase): natural_foreign=True, schema_name=get_public_schema_name(), stdout=out) - self.assertItemsEqual( + self.assertEqual( json.loads('[{"fields": {"domain_url": "localhost", "schema_name": "public"}, ' '"model": "tenant_schemas.tenant", "pk": 1}]'), json.loads(out.getvalue())) diff --git a/tox.ini b/tox.ini index 8a2909e..f411a63 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,10 @@ [tox] -envlist = py27-dj{18,19,110} +envlist = py{27,35}-dj{18,19,110} [testenv] basepython = py27: python2.7 + py35: python3.5 usedevelop = True deps = From ca7536be55a94ee65c1667e3090bb92cc008d74e Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 25 Aug 2016 00:20:52 +1000 Subject: [PATCH 060/157] Add travis-ci.org integration --- .travis.yml | 25 +++++++++++++++++++ dts_test_project/dts_test_project/settings.py | 4 +-- tox.ini | 5 +--- 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b989a40 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +language: python + +python: + - 2.7 + - 3.5 + +services: + - postgresql + +addons: + postgresql: "9.4" + +install: + - pip install tox + +before_script: + - psql -c "CREATE DATABASE dts_test_project;" -U postgres + +script: + - tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} + +env: + - DJANGO=1.8 + - DJANGO=1.9 + - DJANGO=1.10 diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index dcd47ff..ae8de72 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -70,10 +70,10 @@ WSGI_APPLICATION = 'dts_test_project.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'tenant_schemas.postgresql_backend', - 'NAME': 'dts_test_project', + 'NAME': os.environ.get('PG_NAME', 'dts_test_project'), 'USER': os.environ.get('PG_USER', 'postgres'), 'PASSWORD': os.environ.get('PG_PASSWORD', 'root'), - 'HOST': '127.0.0.1', + 'HOST': os.environ.get('PG_HOST', 'localhost'), } } diff --git a/tox.ini b/tox.ini index f411a63..bb7edff 100644 --- a/tox.ini +++ b/tox.ini @@ -2,9 +2,6 @@ envlist = py{27,35}-dj{18,19,110} [testenv] -basepython = - py27: python2.7 - py35: python3.5 usedevelop = True deps = @@ -15,7 +12,7 @@ deps = changedir = dts_test_project -passenv = PG_USER PG_PASSWORD +passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST commands = coverage run manage.py test --noinput {posargs:tenant_schemas.tests -v 2} From 9ba410f3886953b8234409851e70534305044c55 Mon Sep 17 00:00:00 2001 From: Prashanth Jonnala Date: Thu, 10 Nov 2016 11:25:23 -0500 Subject: [PATCH 061/157] Updated indexes ordering introspection for PostgreSQL 9.6 --- tenant_schemas/postgresql_backend/introspection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 255eddd..4128e1e 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -140,8 +140,8 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): WHEN idx.indexprs IS NOT NULL THEN pg_get_indexdef(idx.indexrelid) END AS exprdef, - CASE - WHEN am.amcanorder THEN + CASE am.amname + WHEN 'btree' THEN CASE (option & 1) WHEN 1 THEN 'DESC' ELSE 'ASC' END From acb14ec5ce5206b8fd66abb087764015db4c0594 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 11 Nov 2016 07:34:01 +1100 Subject: [PATCH 062/157] Add Travis CI badge to README --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b76977f..5fe98e2 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ django-tenant-schemas ===================== -|PyPi version| |PyPi downloads| +|PyPi version| |PyPi downloads| |Travis CI| This application enables `django`_ powered websites to have multiple tenants via `PostgreSQL schemas`_. A vital feature for every @@ -204,5 +204,7 @@ tenant specific apps. Complete instructions can be found at :target: https://pypi.python.org/pypi/django-tenant-schemas .. |PyPi downloads| image:: https://img.shields.io/pypi/dm/django-tenant-schemas.svg :target: https://pypi.python.org/pypi/django-tenant-schemas +.. |Travis CI| image:: https://travis-ci.org/bernardopires/django-tenant-schemas.svg?branch=master + :target: https://travis-ci.org/bernardopires/django-tenant-schemas .. _setup: https://django-tenant-schemas.readthedocs.io/en/latest/install.html .. _django-tenant-schemas.readthedocs.io: https://django-tenant-schemas.readthedocs.io/en/latest/ From ea95f3f7a437e4eefd27f7feb562c18e96934d12 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 4 Nov 2016 07:04:48 +1100 Subject: [PATCH 063/157] Refactor ImproperlyConfigured to system check framework - ensure TENANT_APPS is defined in settings.py - ensure TENANT_MODEL is defined in settings.py - ensure TenantSyncRouter appears in DATABASE_ROUTERS - ensure public schema and any existing tenant schemas are not listed in PG_EXTRA_SEARCH_PATHS - raise error when TENANT_APPS is empty - issue warning when 'tenant_schemas' is not the last item in INSTALLED_APPS - issue warning when items in TENANT_APPS are not in INSTALLED_APPS - add checks for SHARED_APPS - add test cases for the best_practice system check - update install documentation which previously suggested concatenation - fix import path of get_public_schema_name - fix failing test case for TenantContextFilter - update tox.ini - add .travis.yml - PEP8 fixes --- .editorconfig | 20 +++ docs/install.rst | 13 +- dts_test_project/.gitignore | 1 + dts_test_project/dts_test_project/settings.py | 11 +- dts_test_project/dts_test_project/urls.py | 22 +--- .../tenant_tutorial/settings.py | 3 +- .../tenant_tutorial/tenant_tutorial/wsgi.py | 2 +- setup.cfg | 3 + tenant_schemas/__init__.py | 41 +----- tenant_schemas/apps.py | 78 ++++++++++++ .../management/commands/migrate_schemas.py | 7 +- tenant_schemas/template_loaders.py | 2 +- tenant_schemas/tests/models.py | 1 - tenant_schemas/tests/test_apps.py | 119 ++++++++++++++++++ tenant_schemas/tests/test_log.py | 16 ++- tenant_schemas/tests/test_routes.py | 2 +- tenant_schemas/tests/test_tenants.py | 2 +- tenant_schemas/tests/testcases.py | 3 +- tenant_schemas/urlresolvers.py | 8 +- tenant_schemas/utils.py | 4 +- tox.ini | 18 +-- version.py | 3 +- 22 files changed, 286 insertions(+), 93 deletions(-) create mode 100644 .editorconfig create mode 100644 dts_test_project/.gitignore create mode 100644 setup.cfg create mode 100644 tenant_schemas/apps.py create mode 100644 tenant_schemas/tests/test_apps.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3b2bec5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{js,py}] +charset = utf-8 + +[*.py] +indent_style = space +indent_size = 4 + +[*.{css,js,less}] +indent_style = space +indent_size = 2 diff --git a/docs/install.rst b/docs/install.rst index 9ffd82f..80c2e5a 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -103,7 +103,18 @@ To make use of shared and tenant-specific applications, there are two settings c 'myapp.houses', ) - INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] + INSTALLED_APPS = ( + 'customers', + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'myapp.hotels', + 'myapp.houses', + 'tenant_schemas', + ) You also have to set where your tenant model is. diff --git a/dts_test_project/.gitignore b/dts_test_project/.gitignore new file mode 100644 index 0000000..6350e98 --- /dev/null +++ b/dts_test_project/.gitignore @@ -0,0 +1 @@ +.coverage diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index ae8de72..66f4245 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -48,7 +48,16 @@ TENANT_MODEL = "customers.Client" # app.Model TEST_RUNNER = 'django.test.runner.DiscoverRunner' -INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS)) +INSTALLED_APPS = ( + 'dts_test_app', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'tenant_schemas', +) MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/dts_test_project/dts_test_project/urls.py b/dts_test_project/dts_test_project/urls.py index a000786..637600f 100644 --- a/dts_test_project/dts_test_project/urls.py +++ b/dts_test_project/dts_test_project/urls.py @@ -1,21 +1 @@ -"""wtf URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.9/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Add an import: from blog import urls as blog_urls - 2. Import the include() function: from django.conf.urls import url, include - 3. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) -""" -from django.conf.urls import url -from django.contrib import admin - -urlpatterns = [ -] +urlpatterns = [] diff --git a/examples/tenant_tutorial/tenant_tutorial/settings.py b/examples/tenant_tutorial/tenant_tutorial/settings.py index c0d8b5a..0a3d190 100644 --- a/examples/tenant_tutorial/tenant_tutorial/settings.py +++ b/examples/tenant_tutorial/tenant_tutorial/settings.py @@ -1,3 +1,5 @@ +import os + # Django settings for tenant_tutorial project. DEBUG = True @@ -121,7 +123,6 @@ PUBLIC_SCHEMA_URLCONF = 'tenant_tutorial.urls_public' # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'tenant_tutorial.wsgi.application' -import os TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), '..', 'templates').replace('\\', '/'),) SHARED_APPS = ( diff --git a/examples/tenant_tutorial/tenant_tutorial/wsgi.py b/examples/tenant_tutorial/tenant_tutorial/wsgi.py index c326bd8..32a9fb2 100644 --- a/examples/tenant_tutorial/tenant_tutorial/wsgi.py +++ b/examples/tenant_tutorial/tenant_tutorial/wsgi.py @@ -14,6 +14,7 @@ framework. """ import os +from django.core.wsgi import get_wsgi_application # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks # if running multiple sites in the same mod_wsgi process. To fix this, use @@ -24,7 +25,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tenant_tutorial.settings") # This application object is used by any WSGI server configured to use this # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. -from django.core.wsgi import get_wsgi_application application = get_wsgi_application() # Apply WSGI middleware here. diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..315d73c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +exclude = .tox,docs,build,migrations,__init__.py +ignore = C901,E501,E731 diff --git a/tenant_schemas/__init__.py b/tenant_schemas/__init__.py index c8b5093..7aa0d8a 100644 --- a/tenant_schemas/__init__.py +++ b/tenant_schemas/__init__.py @@ -1,40 +1 @@ -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - -from tenant_schemas.utils import get_public_schema_name, get_tenant_model - -recommended_config = """ -Warning: You should put 'tenant_schemas' at the end of INSTALLED_APPS: -INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',) -This is necessary to overwrite built-in django management commands with -their schema-aware implementations. -""" -# Test for configuration recommendations. These are best practices, -# they avoid hard to find bugs and unexpected behaviour. -if not hasattr(settings, 'TENANT_APPS'): - raise ImproperlyConfigured('TENANT_APPS setting not set') - -if not settings.TENANT_APPS: - raise ImproperlyConfigured("TENANT_APPS is empty. " - "Maybe you don't need this app?") - -if not hasattr(settings, 'TENANT_MODEL'): - raise ImproperlyConfigured('TENANT_MODEL setting not set') - -if 'tenant_schemas.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS: - raise ImproperlyConfigured("DATABASE_ROUTERS setting must contain " - "'tenant_schemas.routers.TenantSyncRouter'.") - -if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): - if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: - raise ImproperlyConfigured( - "%s can not be included on PG_EXTRA_SEARCH_PATHS." - % get_public_schema_name()) - - # make sure no tenant schema is in settings.PG_EXTRA_SEARCH_PATHS - invalid_schemas = set(settings.PG_EXTRA_SEARCH_PATHS).intersection( - get_tenant_model().objects.all().values_list('schema_name', flat=True)) - if invalid_schemas: - raise ImproperlyConfigured( - "Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS." - % list(invalid_schemas)) +default_app_config = 'tenant_schemas.apps.TenantSchemaConfig' diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py new file mode 100644 index 0000000..68d35f2 --- /dev/null +++ b/tenant_schemas/apps.py @@ -0,0 +1,78 @@ +from django.apps import AppConfig +from django.conf import settings +from django.core.checks import Critical, Error, Warning, register + +from tenant_schemas.utils import get_public_schema_name, get_tenant_model + + +class TenantSchemaConfig(AppConfig): + name = 'tenant_schemas' + + +@register('config') +def best_practice(app_configs, **kwargs): + """ + Test for configuration recommendations. These are best practices, they + avoid hard to find bugs and unexpected behaviour. + """ + if not hasattr(settings, 'TENANT_APPS'): + return [Critical('TENANT_APPS setting not set')] + + if not hasattr(settings, 'TENANT_MODEL'): + return [Critical('TENANT_MODEL setting not set')] + + if not hasattr(settings, 'SHARED_APPS'): + return [Critical('SHARED_APPS setting not set')] + + if 'tenant_schemas.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS: + return [ + Critical("DATABASE_ROUTERS setting must contain " + "'tenant_schemas.routers.TenantSyncRouter'.") + ] + + errors = [] + + if settings.INSTALLED_APPS[-1] != 'tenant_schemas': + errors.append( + Warning("You should put 'tenant_schemas' at the end of INSTALLED_APPS.", + obj="django.conf.settings", + hint="This is necessary to overwrite built-in django " + "management commands with their schema-aware " + "implementations.")) + + if not settings.TENANT_APPS: + errors.append( + Error("TENANT_APPS is empty.", + hint="Maybe you don't need this app?")) + + if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): + if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: + errors.append(Critical( + "%s can not be included on PG_EXTRA_SEARCH_PATHS." + % get_public_schema_name())) + + # make sure no tenant schema is in settings.PG_EXTRA_SEARCH_PATHS + invalid_schemas = set(settings.PG_EXTRA_SEARCH_PATHS).intersection( + get_tenant_model().objects.all().values_list('schema_name', flat=True)) + if invalid_schemas: + errors.append(Critical( + "Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS." + % ", ".join(sorted(invalid_schemas)))) + + if not settings.SHARED_APPS: + errors.append( + Warning("SHARED_APPS is empty.")) + + if not set(settings.TENANT_APPS).issubset(settings.INSTALLED_APPS): + delta = set(settings.TENANT_APPS).difference(settings.INSTALLED_APPS) + errors.append( + Error("You have TENANT_APPS that are not in INSTALLED_APPS", + hint=[a for a in settings.TENANT_APPS if a in delta])) + + if not set(settings.SHARED_APPS).issubset(settings.INSTALLED_APPS): + delta = set(settings.SHARED_APPS).difference(settings.INSTALLED_APPS) + errors.append( + Error("You have SHARED_APPS that are not in INSTALLED_APPS", + hint=[a for a in settings.SHARED_APPS if a in delta])) + + return errors diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 7886ff3..7878629 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -4,6 +4,9 @@ from django.conf import settings from django.core.management.commands.migrate import Command as MigrateCommand from django.db import connection +from tenant_schemas.management.commands import SyncCommon +from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists + if django.VERSION >= (1, 9, 0): from django.db.migrations.exceptions import MigrationSchemaMissing else: @@ -11,10 +14,6 @@ else: pass -from tenant_schemas.management.commands import SyncCommon -from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists - - class Command(SyncCommon): help = "Updates database schema. Manages both apps with migrations and those without." diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 583793a..c8c98ee 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -13,7 +13,7 @@ from django.db import connection from django.template.loaders.base import Loader as BaseLoader from tenant_schemas.postgresql_backend.base import FakeTenant -import django + class CachedLoader(BaseLoader): is_usable = True diff --git a/tenant_schemas/tests/models.py b/tenant_schemas/tests/models.py index da1ba7b..054fe8a 100644 --- a/tenant_schemas/tests/models.py +++ b/tenant_schemas/tests/models.py @@ -1,4 +1,3 @@ -from django.db import models from tenant_schemas.models import TenantMixin diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py new file mode 100644 index 0000000..e841411 --- /dev/null +++ b/tenant_schemas/tests/test_apps.py @@ -0,0 +1,119 @@ +from django.apps.registry import Apps +from django.core.checks import Critical, Error, Warning +from django.test import TestCase +from django.test.utils import override_settings + +from tenant_schemas.apps import best_practice +from tenant_schemas.utils import get_tenant_model + + +class AppConfigTests(TestCase): + + maxDiff = None + + def assertBestPractice(self, expected): + from django.conf import settings + registry = Apps(settings.INSTALLED_APPS) + actual = best_practice(registry.get_app_configs()) + self.assertEqual(expected, actual) + + @override_settings() + def test_unset_tenant_apps(self): + from django.conf import settings + del settings.TENANT_APPS + self.assertBestPractice([ + Critical('TENANT_APPS setting not set'), + ]) + + @override_settings() + def test_unset_tenant_model(self): + from django.conf import settings + del settings.TENANT_MODEL + self.assertBestPractice([ + Critical('TENANT_MODEL setting not set'), + ]) + + @override_settings() + def test_unset_shared_apps(self): + from django.conf import settings + del settings.SHARED_APPS + self.assertBestPractice([ + Critical('SHARED_APPS setting not set'), + ]) + + @override_settings(DATABASE_ROUTERS=()) + def test_database_routers(self): + self.assertBestPractice([ + Critical("DATABASE_ROUTERS setting must contain " + "'tenant_schemas.routers.TenantSyncRouter'."), + ]) + + @override_settings(INSTALLED_APPS=[ + 'tenant_schemas', + 'dts_test_app', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + ]) + def test_tenant_schemas_last_installed_apps(self): + self.assertBestPractice([ + Warning("You should put 'tenant_schemas' at the end of INSTALLED_APPS.", + obj="django.conf.settings", + hint="This is necessary to overwrite built-in django " + "management commands with their schema-aware " + "implementations."), + ]) + + @override_settings(TENANT_APPS=()) + def test_tenant_apps_empty(self): + self.assertBestPractice([ + Error("TENANT_APPS is empty.", + hint="Maybe you don't need this app?"), + ]) + + @override_settings(PG_EXTRA_SEARCH_PATHS=['public', 'demo1', 'demo2']) + def test_public_schema_on_extra_search_paths(self): + TenantModel = get_tenant_model() + TenantModel.objects.create( + schema_name='demo1', domain_url='demo1.example.com') + TenantModel.objects.create( + schema_name='demo2', domain_url='demo2.example.com') + self.assertBestPractice([ + Critical("public can not be included on PG_EXTRA_SEARCH_PATHS."), + Critical("Do not include tenant schemas (demo1, demo2) on PG_EXTRA_SEARCH_PATHS."), + ]) + + @override_settings(SHARED_APPS=()) + def test_shared_apps_empty(self): + self.assertBestPractice([ + Warning("SHARED_APPS is empty."), + ]) + + @override_settings(TENANT_APPS=( + 'dts_test_app', + 'django.contrib.flatpages', + )) + def test_tenant_app_missing_from_install_apps(self): + self.assertBestPractice([ + Error("You have TENANT_APPS that are not in INSTALLED_APPS", + hint=['django.contrib.flatpages']), + ]) + + @override_settings(SHARED_APPS=( + 'tenant_schemas', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.flatpages', + 'django.contrib.messages', + 'django.contrib.sessions', + 'django.contrib.staticfiles', + )) + def test_shared_app_missing_from_install_apps(self): + self.assertBestPractice([ + Error("You have SHARED_APPS that are not in INSTALLED_APPS", + hint=['django.contrib.flatpages']), + ]) diff --git a/tenant_schemas/tests/test_log.py b/tenant_schemas/tests/test_log.py index a145d15..4dd27dd 100644 --- a/tenant_schemas/tests/test_log.py +++ b/tenant_schemas/tests/test_log.py @@ -1,16 +1,28 @@ import logging +from mock import patch from django.test import TestCase from tenant_schemas import log +@patch('tenant_schemas.log.connection.tenant', autospec=True, + schema_name='context') class LoggingFilterTests(TestCase): - def test_tenant_context_filter(self): + def test_tenant_context_filter(self, mock_connection): + mock_connection.domain_url = 'context.example.com' filter_ = log.TenantContextFilter() record = logging.makeLogRecord({}) res = filter_.filter(record) self.assertEqual(res, True) - self.assertEqual(record.schema_name, 'public') + self.assertEqual(record.schema_name, 'context') + self.assertEqual(record.domain_url, 'context.example.com') + + def test_tenant_context_filter_blank_domain_url(self, mock_connection): + filter_ = log.TenantContextFilter() + record = logging.makeLogRecord({}) + res = filter_.filter(record) + self.assertEqual(res, True) + self.assertEqual(record.schema_name, 'context') self.assertEqual(record.domain_url, '') diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 0af0afd..6a5bcb1 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -1,10 +1,10 @@ from django.conf import settings from django.test.client import RequestFactory -from tenant_schemas import get_public_schema_name from tenant_schemas.middleware import TenantMiddleware from tenant_schemas.tests.models import Tenant from tenant_schemas.tests.testcases import BaseTestCase +from tenant_schemas.utils import get_public_schema_name class RoutesTestCase(BaseTestCase): diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index c6b222b..5dd27e7 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -87,7 +87,7 @@ class TenantDataAndSettingsTest(BaseTestCase): self.assertFalse(schema_exists('auto_drop_tenant')) Tenant.auto_drop_schema = True tenant = Tenant(domain_url='something.test.com', - schema_name='auto_drop_tenant') + schema_name='auto_drop_tenant') tenant.save(verbosity=BaseTestCase.get_verbosity()) self.assertTrue(schema_exists(tenant.schema_name)) cursor = connection.cursor() diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 25ff443..75a923a 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -1,4 +1,3 @@ -import django import inspect from django.conf import settings from django.core.management import call_command @@ -27,7 +26,7 @@ class BaseTestCase(TestCase): connection.set_schema_to_public() cursor = connection.cursor() cursor.execute('DROP SCHEMA %s CASCADE; CREATE SCHEMA %s;' - % (get_public_schema_name(), get_public_schema_name(), )) + % (get_public_schema_name(), get_public_schema_name())) super(BaseTestCase, cls).setUpClass() def setUp(self): diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index 00e36f6..0351a8a 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -6,10 +6,10 @@ from tenant_schemas.utils import clean_tenant_url def reverse(viewname, urlconf=None, args=None, kwargs=None, prefix=None, current_app=None): url = reverse_default( - viewname=viewname, - urlconf=urlconf, - args=args, - kwargs=kwargs, + viewname=viewname, + urlconf=urlconf, + args=args, + kwargs=kwargs, current_app=current_app ) return clean_tenant_url(url) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 9054125..338ebc6 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -55,8 +55,8 @@ def clean_tenant_url(url_string): Removes the TENANT_TOKEN from a particular string """ if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF'): - if (settings.PUBLIC_SCHEMA_URLCONF - and url_string.startswith(settings.PUBLIC_SCHEMA_URLCONF)): + if (settings.PUBLIC_SCHEMA_URLCONF and + url_string.startswith(settings.PUBLIC_SCHEMA_URLCONF)): url_string = url_string[len(settings.PUBLIC_SCHEMA_URLCONF):] return url_string diff --git a/tox.ini b/tox.ini index bb7edff..7f2d7c6 100644 --- a/tox.ini +++ b/tox.ini @@ -4,16 +4,18 @@ envlist = py{27,35}-dj{18,19,110} [testenv] usedevelop = True -deps = - coverage - dj18: Django~=1.8.0 - dj19: Django~=1.9.0 - dj110: Django~=1.10.0 +deps = + coverage + mock + tblib + dj18: Django~=1.8.0 + dj19: Django~=1.9.0 + dj110: Django~=1.10.0 changedir = dts_test_project passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST -commands = - coverage run manage.py test --noinput {posargs:tenant_schemas.tests -v 2} - coverage report -m --include=../tenant_schemas/* +commands = + coverage run manage.py test --noinput {posargs:tenant_schemas} + coverage report -m --include=../tenant_schemas/* diff --git a/version.py b/version.py index c1e513f..9a235c5 100644 --- a/version.py +++ b/version.py @@ -30,11 +30,10 @@ # contains the following line: # # include VERSION +from subprocess import Popen, PIPE __all__ = ("get_git_version") -from subprocess import Popen, PIPE - def call_git_describe(): try: From 6df7a1c01924d455fa7778507cf49e339c5d5b79 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 11 Nov 2016 07:56:04 +1100 Subject: [PATCH 064/157] Adjust to check that tenant_schemas is listed first in INSTALLED_APPS The Django documentation suggests that the correct way to overload management commands with the same name is to load them earlier in the INSTALLED_APPS list. --- dts_test_project/dts_test_project/settings.py | 2 +- tenant_schemas/apps.py | 4 ++-- tenant_schemas/tests/test_apps.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 66f4245..ae8c46c 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -49,6 +49,7 @@ TENANT_MODEL = "customers.Client" # app.Model TEST_RUNNER = 'django.test.runner.DiscoverRunner' INSTALLED_APPS = ( + 'tenant_schemas', 'dts_test_app', 'customers', 'django.contrib.auth', @@ -56,7 +57,6 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'tenant_schemas', ) MIDDLEWARE_CLASSES = ( diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index 68d35f2..a7d7b65 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -32,9 +32,9 @@ def best_practice(app_configs, **kwargs): errors = [] - if settings.INSTALLED_APPS[-1] != 'tenant_schemas': + if settings.INSTALLED_APPS[0] != 'tenant_schemas': errors.append( - Warning("You should put 'tenant_schemas' at the end of INSTALLED_APPS.", + Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py index e841411..0b80fd1 100644 --- a/tenant_schemas/tests/test_apps.py +++ b/tenant_schemas/tests/test_apps.py @@ -49,18 +49,18 @@ class AppConfigTests(TestCase): ]) @override_settings(INSTALLED_APPS=[ - 'tenant_schemas', 'dts_test_app', 'customers', 'django.contrib.auth', 'django.contrib.contenttypes', + 'tenant_schemas', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ]) def test_tenant_schemas_last_installed_apps(self): self.assertBestPractice([ - Warning("You should put 'tenant_schemas' at the end of INSTALLED_APPS.", + Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " From d935526c478f631566a1d54e0742d1d241e24d02 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 11 Nov 2016 17:10:24 +1100 Subject: [PATCH 065/157] Flatten the app_configs to an INSTALLED_APPS list equivalence --- tenant_schemas/apps.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index a7d7b65..145b800 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -15,6 +15,13 @@ def best_practice(app_configs, **kwargs): Test for configuration recommendations. These are best practices, they avoid hard to find bugs and unexpected behaviour. """ + # Take the app_configs and turn them into *old style* application names. + # This is what we expect in the SHARED_APPS and TENANT_APPS settings. + INSTALLED_APPS = [ + config.name + for config in app_configs + ] + if not hasattr(settings, 'TENANT_APPS'): return [Critical('TENANT_APPS setting not set')] @@ -32,7 +39,7 @@ def best_practice(app_configs, **kwargs): errors = [] - if settings.INSTALLED_APPS[0] != 'tenant_schemas': + if INSTALLED_APPS[0] != 'tenant_schemas': errors.append( Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", obj="django.conf.settings", @@ -63,14 +70,14 @@ def best_practice(app_configs, **kwargs): errors.append( Warning("SHARED_APPS is empty.")) - if not set(settings.TENANT_APPS).issubset(settings.INSTALLED_APPS): - delta = set(settings.TENANT_APPS).difference(settings.INSTALLED_APPS) + if not set(settings.TENANT_APPS).issubset(INSTALLED_APPS): + delta = set(settings.TENANT_APPS).difference(INSTALLED_APPS) errors.append( Error("You have TENANT_APPS that are not in INSTALLED_APPS", hint=[a for a in settings.TENANT_APPS if a in delta])) - if not set(settings.SHARED_APPS).issubset(settings.INSTALLED_APPS): - delta = set(settings.SHARED_APPS).difference(settings.INSTALLED_APPS) + if not set(settings.SHARED_APPS).issubset(INSTALLED_APPS): + delta = set(settings.SHARED_APPS).difference(INSTALLED_APPS) errors.append( Error("You have SHARED_APPS that are not in INSTALLED_APPS", hint=[a for a in settings.SHARED_APPS if a in delta])) From 7d2d8e8db5c8079050b5b5eebd0af6aa5dc960e4 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 11 Nov 2016 17:17:08 +1100 Subject: [PATCH 066/157] Query the apps registry in test cases The override_settings module looks after revising the cache, so no need to build our own - the settings.INSTALLED_APPS was holding unexpected values (strangely only in Python 3.5, though) but this should give the expected outcome every time. --- tenant_schemas/tests/test_apps.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py index 0b80fd1..a139b3f 100644 --- a/tenant_schemas/tests/test_apps.py +++ b/tenant_schemas/tests/test_apps.py @@ -1,4 +1,4 @@ -from django.apps.registry import Apps +from django.apps import apps from django.core.checks import Critical, Error, Warning from django.test import TestCase from django.test.utils import override_settings @@ -12,9 +12,7 @@ class AppConfigTests(TestCase): maxDiff = None def assertBestPractice(self, expected): - from django.conf import settings - registry = Apps(settings.INSTALLED_APPS) - actual = best_practice(registry.get_app_configs()) + actual = best_practice(apps.get_app_configs()) self.assertEqual(expected, actual) @override_settings() From 2190883129d25363d8dd3aa81a6bf27ee3f5bfc9 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 11 Nov 2016 07:43:49 +1100 Subject: [PATCH 067/157] Document a position on supported versions of Django & PostgreSQL - policy for Django (all currently supported versions) - policy for PostgreSQL (minimum version of latest Django version) Upped the link to PostgreSQL's schema documentation to match new minimum (9.2). --- docs/index.rst | 4 ++-- docs/use.rst | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 5d634d9..d47d9b4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ Welcome to django-tenant-schemas documentation! =============================================== -This application enables `Django `_ powered websites to have multiple tenants via `PostgreSQL schemas `_. A vital feature for every Software-as-a-Service website. +This application enables `Django `_ powered websites to have multiple tenants via `PostgreSQL schemas `_. A vital feature for every Software-as-a-Service website. Django provides currently no simple way to support multiple tenants using the same project instance, even when only the data is different. Because we don't want you running many copies of your project, you'll be able to have: @@ -10,7 +10,7 @@ Django provides currently no simple way to support multiple tenants using the sa What are schemas? ----------------- -A schema can be seen as a directory in an operating system, each directory (schema) with it's own set of files (tables and objects). This allows the same table name and objects to be used in different schemas without conflict. For an accurate description on schemas, see `PostgreSQL's official documentation on schemas `_. +A schema can be seen as a directory in an operating system, each directory (schema) with it's own set of files (tables and objects). This allows the same table name and objects to be used in different schemas without conflict. For an accurate description on schemas, see `PostgreSQL's official documentation on schemas `_. Why schemas? ------------ diff --git a/docs/use.rst b/docs/use.rst index aea9028..8db61d8 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -1,6 +1,12 @@ =========================== Using django-tenant-schemas =========================== +Supported versions +------------------ +You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. + +It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 9.2, the minimum for Django 1.10. + Creating a Tenant ----------------- Creating a tenant works just like any other model in django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. From 835c49b930ccf272be5c09b302a0b3f8379d3d46 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 11 Nov 2016 08:44:16 +1100 Subject: [PATCH 068/157] Update README.rst and setup.py - update package classifiers to make the pyversions badge work - add badges for PostgreSQL versions supported --- README.rst | 4 +++- setup.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5fe98e2..713392b 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ django-tenant-schemas ===================== -|PyPi version| |PyPi downloads| |Travis CI| +|PyPi version| |PyPi downloads| |Python versions| |Travis CI| |PostgreSQL| This application enables `django`_ powered websites to have multiple tenants via `PostgreSQL schemas`_. A vital feature for every @@ -204,7 +204,9 @@ tenant specific apps. Complete instructions can be found at :target: https://pypi.python.org/pypi/django-tenant-schemas .. |PyPi downloads| image:: https://img.shields.io/pypi/dm/django-tenant-schemas.svg :target: https://pypi.python.org/pypi/django-tenant-schemas +.. |Python versions| image:: https://img.shields.io/pypi/pyversions/django-tenant-schemas.svg .. |Travis CI| image:: https://travis-ci.org/bernardopires/django-tenant-schemas.svg?branch=master :target: https://travis-ci.org/bernardopires/django-tenant-schemas +.. |PostgreSQL| image:: https://img.shields.io/badge/PostgreSQL-9.2%2C%209.3%2C%209.4%2C%209.5%2C%209.6-blue.svg .. _setup: https://django-tenant-schemas.readthedocs.io/en/latest/install.html .. _django-tenant-schemas.readthedocs.io: https://django-tenant-schemas.readthedocs.io/en/latest/ diff --git a/setup.py b/setup.py index cbfdaa5..c201fd6 100755 --- a/setup.py +++ b/setup.py @@ -30,7 +30,14 @@ setup( classifiers=[ 'License :: OSI Approved :: MIT License', 'Framework :: Django', + 'Framework :: Django :: 1.8', + 'Framework :: Django :: 1.9', + 'Framework :: Django :: 1.10', 'Programming Language :: Python', + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Database", + "Topic :: Software Development :: Libraries", ], install_requires=[ 'Django >= 1.8.0', From 72b828ad383f5f3b2bb191517ea34c6a0030060c Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 28 Nov 2016 23:51:28 +0100 Subject: [PATCH 069/157] Fixes #411 --- .travis.yml | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index b989a40..7acb6a1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,25 +1,26 @@ language: python - python: - - 2.7 - - 3.5 - +- 2.7 +- 3.5 services: - - postgresql - +- postgresql addons: - postgresql: "9.4" - + postgresql: '9.4' install: - - pip install tox - +- pip install tox before_script: - - psql -c "CREATE DATABASE dts_test_project;" -U postgres - +- psql -c "CREATE DATABASE dts_test_project;" -U postgres script: - - tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} - +- tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} env: - - DJANGO=1.8 - - DJANGO=1.9 - - DJANGO=1.10 +- DJANGO=1.8 +- DJANGO=1.9 +- DJANGO=1.10 +deploy: + provider: pypi + user: bcarneiro + password: + secure: h4cyG6Fyz9TakmaY4SkjDBcfEqBqGfqCDsnCO9YRsKbKu8ShheKNAtPFazARHxF0YRzj8Jy9+fr47yj3TO5MYfJI6WQnQt+01KWX0Wip6eBkThyVbsRCZTPqr2iNgHjTCdsJKgUYS9tUFc0u3ItykbG9QaMDXMmKgZWNWuEWDb32rc7X2GSHO/EwIIRWqZNYjb2JI7eRI6XS/0hssmOk/z4WLAfVHHHTA7GiFENfEcvPU4r9mKmr7CVpTSxK/LbqNGYyQqNfuZIV96Sh2ZECMObYbV2O3JezTUgIUrsh0Wk5EK3TG5+ynvwk4k7gPICwYmWCQ0zVbHuMdG8s0LtCKyy0UerBZkf/Y0y/TZN7aAZkC9FBok+6R4IcDKnoJ6yCl7Rwjx/hOjMGJH32xC3EKitlaaBN/LXYeGRrJ5FybsFp7yn0kR0OlvhqlAsjYsbxgYM05qYsn4E6XB5lHqDtu1/pJj6eT1RAWe8aYseGlL56ia6mWUsCMmZxwXtYHVZMV/GGuUZrlOjn5wZXUqAy4W7A5MZreEFooVERDmxJrULoekdAfiHzEWsPiSP5faP0t29YhQoiqvzRLZduLerNmWIa/dd9syvHvldn/ZrJqE/hbghpGpjAtlEzwtIMqU0xIX+zL3xuPKW0IaYI7slbO9CBaWA33xMLzn79kUnm04Q= + on: + distributions: sdist bdist_wheel + repo: bernardopires/django-tenant-schemas From 73a049526732e4f58a5a3c95f491610de4b78137 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 1 Dec 2016 06:24:49 +1100 Subject: [PATCH 070/157] Update .travis.yml to trigger deploy on tags Wrapped the encrypted password to fit on narrow displays, including github.com --- .travis.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7acb6a1..537f43d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,18 @@ deploy: provider: pypi user: bcarneiro password: - secure: h4cyG6Fyz9TakmaY4SkjDBcfEqBqGfqCDsnCO9YRsKbKu8ShheKNAtPFazARHxF0YRzj8Jy9+fr47yj3TO5MYfJI6WQnQt+01KWX0Wip6eBkThyVbsRCZTPqr2iNgHjTCdsJKgUYS9tUFc0u3ItykbG9QaMDXMmKgZWNWuEWDb32rc7X2GSHO/EwIIRWqZNYjb2JI7eRI6XS/0hssmOk/z4WLAfVHHHTA7GiFENfEcvPU4r9mKmr7CVpTSxK/LbqNGYyQqNfuZIV96Sh2ZECMObYbV2O3JezTUgIUrsh0Wk5EK3TG5+ynvwk4k7gPICwYmWCQ0zVbHuMdG8s0LtCKyy0UerBZkf/Y0y/TZN7aAZkC9FBok+6R4IcDKnoJ6yCl7Rwjx/hOjMGJH32xC3EKitlaaBN/LXYeGRrJ5FybsFp7yn0kR0OlvhqlAsjYsbxgYM05qYsn4E6XB5lHqDtu1/pJj6eT1RAWe8aYseGlL56ia6mWUsCMmZxwXtYHVZMV/GGuUZrlOjn5wZXUqAy4W7A5MZreEFooVERDmxJrULoekdAfiHzEWsPiSP5faP0t29YhQoiqvzRLZduLerNmWIa/dd9syvHvldn/ZrJqE/hbghpGpjAtlEzwtIMqU0xIX+zL3xuPKW0IaYI7slbO9CBaWA33xMLzn79kUnm04Q= + secure: | + h4cyG6Fyz9TakmaY4SkjDBcfEqBqGfqCDsnCO9YRsKbKu8ShheKNAtPFazARHxF0YRzj8Jy9 + +fr47yj3TO5MYfJI6WQnQt+01KWX0Wip6eBkThyVbsRCZTPqr2iNgHjTCdsJKgUYS9tUFc0u + 3ItykbG9QaMDXMmKgZWNWuEWDb32rc7X2GSHO/EwIIRWqZNYjb2JI7eRI6XS/0hssmOk/z4W + LAfVHHHTA7GiFENfEcvPU4r9mKmr7CVpTSxK/LbqNGYyQqNfuZIV96Sh2ZECMObYbV2O3Jez + TUgIUrsh0Wk5EK3TG5+ynvwk4k7gPICwYmWCQ0zVbHuMdG8s0LtCKyy0UerBZkf/Y0y/TZN7 + aAZkC9FBok+6R4IcDKnoJ6yCl7Rwjx/hOjMGJH32xC3EKitlaaBN/LXYeGRrJ5FybsFp7yn0 + kR0OlvhqlAsjYsbxgYM05qYsn4E6XB5lHqDtu1/pJj6eT1RAWe8aYseGlL56ia6mWUsCMmZx + wXtYHVZMV/GGuUZrlOjn5wZXUqAy4W7A5MZreEFooVERDmxJrULoekdAfiHzEWsPiSP5faP0 + t29YhQoiqvzRLZduLerNmWIa/dd9syvHvldn/ZrJqE/hbghpGpjAtlEzwtIMqU0xIX+zL3xu + PKW0IaYI7slbO9CBaWA33xMLzn79kUnm04Q= on: distributions: sdist bdist_wheel + tags: true repo: bernardopires/django-tenant-schemas From d091d2e3568c07e55cb802bbddae31a305bde7db Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sat, 10 Dec 2016 06:35:19 +1100 Subject: [PATCH 071/157] Update apps.py Populate `app_configs` if it is `None`. --- tenant_schemas/apps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index 145b800..0572563 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -1,4 +1,4 @@ -from django.apps import AppConfig +from django.apps import AppConfig, apps from django.conf import settings from django.core.checks import Critical, Error, Warning, register @@ -15,6 +15,9 @@ def best_practice(app_configs, **kwargs): Test for configuration recommendations. These are best practices, they avoid hard to find bugs and unexpected behaviour. """ + if app_configs is None: + app_configs = apps.get_app_configs() + # Take the app_configs and turn them into *old style* application names. # This is what we expect in the SHARED_APPS and TENANT_APPS settings. INSTALLED_APPS = [ From 76bd6517077d9fe6c60e06d217dbcb66e256891e Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sun, 18 Dec 2016 00:57:59 +1100 Subject: [PATCH 072/157] Try dropping wheel dist to push to PyPI. --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 537f43d..99f77e4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,6 +32,6 @@ deploy: t29YhQoiqvzRLZduLerNmWIa/dd9syvHvldn/ZrJqE/hbghpGpjAtlEzwtIMqU0xIX+zL3xu PKW0IaYI7slbO9CBaWA33xMLzn79kUnm04Q= on: - distributions: sdist bdist_wheel - tags: true + distributions: sdist + branch: master repo: bernardopires/django-tenant-schemas From 03d1c97821d65b00b4a5fa9cac1966ef8d4d92af Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sun, 1 Jan 2017 07:09:39 +0100 Subject: [PATCH 073/157] Trying again the travis auto commit to pypi --- .travis.yml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 99f77e4..214dc84 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,18 +20,7 @@ deploy: provider: pypi user: bcarneiro password: - secure: | - h4cyG6Fyz9TakmaY4SkjDBcfEqBqGfqCDsnCO9YRsKbKu8ShheKNAtPFazARHxF0YRzj8Jy9 - +fr47yj3TO5MYfJI6WQnQt+01KWX0Wip6eBkThyVbsRCZTPqr2iNgHjTCdsJKgUYS9tUFc0u - 3ItykbG9QaMDXMmKgZWNWuEWDb32rc7X2GSHO/EwIIRWqZNYjb2JI7eRI6XS/0hssmOk/z4W - LAfVHHHTA7GiFENfEcvPU4r9mKmr7CVpTSxK/LbqNGYyQqNfuZIV96Sh2ZECMObYbV2O3Jez - TUgIUrsh0Wk5EK3TG5+ynvwk4k7gPICwYmWCQ0zVbHuMdG8s0LtCKyy0UerBZkf/Y0y/TZN7 - aAZkC9FBok+6R4IcDKnoJ6yCl7Rwjx/hOjMGJH32xC3EKitlaaBN/LXYeGRrJ5FybsFp7yn0 - kR0OlvhqlAsjYsbxgYM05qYsn4E6XB5lHqDtu1/pJj6eT1RAWe8aYseGlL56ia6mWUsCMmZx - wXtYHVZMV/GGuUZrlOjn5wZXUqAy4W7A5MZreEFooVERDmxJrULoekdAfiHzEWsPiSP5faP0 - t29YhQoiqvzRLZduLerNmWIa/dd9syvHvldn/ZrJqE/hbghpGpjAtlEzwtIMqU0xIX+zL3xu - PKW0IaYI7slbO9CBaWA33xMLzn79kUnm04Q= + secure: BqLMnjgq+LWIDp1QymMiA4NCN2OdH20EGnQyI5lO/zR5Bo9NRu/vH0PIuYej7V+Sy7RR/AbuBlBmsfqcAqn60lggJevklOImqsJoxjPCcLPa99ICHXAMRIHothqqM7n062ki2uqQfcLMI2j5hTKe8safZAfvnhQnN5y8gXc6WZ5TMDutPeeFnQVkTUHYzQb3xcIO0fK2cSXHbOu0f+TrB9/LZccgAefFxa6LYo9ec+BRMP48t0P3zKjWG7UDJTCyQ/T+nw0nW9vfQrOqorZMH6vtjcIPptEx+E0BvU3v2pEKGdsIg197dK7MVXSIX5ctAPzl6VO/zV+TEpkagYHA1M0MCneoJfXIaUxj0yh4F0lPJB/aQGkrKd/Y2/QcPkhosgCgIjtdQYLrkvOg09tGMSeDQSoLsqxZeRlgsHHPgtiMGLa1bTyt4iUi+Iv9NlfJP2rpyOzvpOYfMgXYFWaxMz60y81u7yOqEHCF+Y23jUs9xmZkY1aD45AM+qodHQ6E3sLL9SaE+p9yfXXlySex94b8wyEksbUSK7FukR0gv7yQKSKOWbe32yWu81owDoBNpumHJmoeE/fe8XC8XltJeuenqVFBUGH0Zx+SiJVKj0pZDWeYV0WpsN22wfYrnTB5S7BFoaxrGRR4p+CIuoxH6mG3lF/5uWHvy62hZykC8Vs= on: - distributions: sdist - branch: master + distributions: sdist bdist_wheel repo: bernardopires/django-tenant-schemas From eb0f6cf6608dcc6b49d9c3d67ab22ddfcd63afa2 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sun, 1 Jan 2017 08:04:33 +0100 Subject: [PATCH 074/157] Second attempt for travis auto deploy to pypi --- .travis.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 214dc84..4598fcf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,4 @@ deploy: provider: pypi user: bcarneiro password: - secure: BqLMnjgq+LWIDp1QymMiA4NCN2OdH20EGnQyI5lO/zR5Bo9NRu/vH0PIuYej7V+Sy7RR/AbuBlBmsfqcAqn60lggJevklOImqsJoxjPCcLPa99ICHXAMRIHothqqM7n062ki2uqQfcLMI2j5hTKe8safZAfvnhQnN5y8gXc6WZ5TMDutPeeFnQVkTUHYzQb3xcIO0fK2cSXHbOu0f+TrB9/LZccgAefFxa6LYo9ec+BRMP48t0P3zKjWG7UDJTCyQ/T+nw0nW9vfQrOqorZMH6vtjcIPptEx+E0BvU3v2pEKGdsIg197dK7MVXSIX5ctAPzl6VO/zV+TEpkagYHA1M0MCneoJfXIaUxj0yh4F0lPJB/aQGkrKd/Y2/QcPkhosgCgIjtdQYLrkvOg09tGMSeDQSoLsqxZeRlgsHHPgtiMGLa1bTyt4iUi+Iv9NlfJP2rpyOzvpOYfMgXYFWaxMz60y81u7yOqEHCF+Y23jUs9xmZkY1aD45AM+qodHQ6E3sLL9SaE+p9yfXXlySex94b8wyEksbUSK7FukR0gv7yQKSKOWbe32yWu81owDoBNpumHJmoeE/fe8XC8XltJeuenqVFBUGH0Zx+SiJVKj0pZDWeYV0WpsN22wfYrnTB5S7BFoaxrGRR4p+CIuoxH6mG3lF/5uWHvy62hZykC8Vs= - on: - distributions: sdist bdist_wheel - repo: bernardopires/django-tenant-schemas + secure: DAx2Go/rcYusHZm1XrGPkZI6FLaWGcgqcmhjqy18Uj4+Wp23pSrkEsS/ERk7ImdRIHZBS1FDLNuJ7qF9ynJckaShF8y8z3lKk70sYQsbe3HKTNXjeKktOMRlOJqG6yHFpZG0LbT2mN1ucyq1FntoY2pIODySy+ZNBcNNbj5AYjGLQafWU+o3qzqcn+n6nGn7znclSBhAyasyFQWoFcuacwMJYA9YG/nOQ3fxB6lLSIORt6gpzTQ/OwbF6UasCkhfCLdjL8S8UM3CemHPcRox+bW0pevo1rGj1K0Cnw+0oynj+96wAinda0wrF3dtKgUSl2vnBN8laccazZir1qjTvVVXc1ggwHg0OJ/03uqDcoXk4tsopSbm5mGTtG4zS/rIt44qEU7Sgs6EzR0XME9Wenxka4Jq7k6BnFzNUqyXjrigWB2brCOH82VQIkbCrqX+AmAXVKIkJhaXM86V3MkRSeKaaE3yE1iLjyNpJsB3BZKgoVH7f+Etboadk4gzc6Di+xAPdYcAexgaCUHulGSU4IaJ/dd+6zjtlQUDG8rnUAGsRanRoYhYH1tsuxVKi/hUfUyv+AcUiEwaEUaH9i7q5xcJFyL8AI/nlU+fkkECQ/V6pPLdA8S/mO/SsQX9MMUvfcml3ucSALJKPUnAn1Nt8i41/tu0AJ2gLieV7RwbPOc= From 6e6c8673efdfbaf7fa5316e4ab50cd55ceeb7371 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sun, 1 Jan 2017 08:07:09 +0100 Subject: [PATCH 075/157] Third attempt at travis autocommit to pypi --- .travis.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4598fcf..5b574b1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,4 +20,8 @@ deploy: provider: pypi user: bcarneiro password: - secure: DAx2Go/rcYusHZm1XrGPkZI6FLaWGcgqcmhjqy18Uj4+Wp23pSrkEsS/ERk7ImdRIHZBS1FDLNuJ7qF9ynJckaShF8y8z3lKk70sYQsbe3HKTNXjeKktOMRlOJqG6yHFpZG0LbT2mN1ucyq1FntoY2pIODySy+ZNBcNNbj5AYjGLQafWU+o3qzqcn+n6nGn7znclSBhAyasyFQWoFcuacwMJYA9YG/nOQ3fxB6lLSIORt6gpzTQ/OwbF6UasCkhfCLdjL8S8UM3CemHPcRox+bW0pevo1rGj1K0Cnw+0oynj+96wAinda0wrF3dtKgUSl2vnBN8laccazZir1qjTvVVXc1ggwHg0OJ/03uqDcoXk4tsopSbm5mGTtG4zS/rIt44qEU7Sgs6EzR0XME9Wenxka4Jq7k6BnFzNUqyXjrigWB2brCOH82VQIkbCrqX+AmAXVKIkJhaXM86V3MkRSeKaaE3yE1iLjyNpJsB3BZKgoVH7f+Etboadk4gzc6Di+xAPdYcAexgaCUHulGSU4IaJ/dd+6zjtlQUDG8rnUAGsRanRoYhYH1tsuxVKi/hUfUyv+AcUiEwaEUaH9i7q5xcJFyL8AI/nlU+fkkECQ/V6pPLdA8S/mO/SsQX9MMUvfcml3ucSALJKPUnAn1Nt8i41/tu0AJ2gLieV7RwbPOc= + secure: IJ23U+zTqDmHB8aB8GEhAAZRzOx5CorPe4Mi6KEyafuALWsY5CB95sN24Il01rhPX+3a88krwu+XNhsjEOVjNn4FE1sEiqO15p7hvIh5sj8JxjVqFvU58rScFTsvYIw4DwvO7cZSOQhFihEHtQRHbWd8b95GGqwRjh2yN1HzMsgOrptQC0Op038S4pSeFRGINj1HJbj320ctbLJoP9GBROpMJ71GR2V2RnTunxcMmlx4yVYEIHS1JijcDQ2BlG8ZikIVfeKqoipzd592Sq2JUuhPgL2MMeI8onHFGRJqUJv3elXev4qcXSqclQtGyTOewB5YZBHrqj3Pg134RmiOxapNWe0HaLPjsrflviJuZepU1ETh6epjcbZi4vl32MCQF/v1nhezoP4BLijJWstKmgmwXuFoDra6niUPcU97e1o5OnFkySlV9IcH+vrAo+sNGt0ztRen3zU+cab8ff0CnH1Me23Tzf3H6gkZCaHlol0xB5u75+5QSpgNAlgQfcbpZVqYsRPpCf61qJixfy/QPBydAWQ5uwG6KzgGTffAmAj4WOa3/n+19jsPNS6h0+VNEgRnoGfIzckQ/hrOV9wPIJ5oJ99SNYLnQ4SLl3JqqttryEHyBSRdYQ7wn3pb9oPfq58/47f/NTPNZRAsemVEx+9udm25+AtEAtS3OB6sQgQ= + on: + tags: true + distributions: sdist bdist_wheel + repo: bernardopires/django-tenant-schemas From 1a83c5222140041ac4d060bfa9a546cc63115168 Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sun, 1 Jan 2017 08:15:19 +0100 Subject: [PATCH 076/157] 4th attempt at travis and pypi --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5b574b1..d83ff55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,6 +22,6 @@ deploy: password: secure: IJ23U+zTqDmHB8aB8GEhAAZRzOx5CorPe4Mi6KEyafuALWsY5CB95sN24Il01rhPX+3a88krwu+XNhsjEOVjNn4FE1sEiqO15p7hvIh5sj8JxjVqFvU58rScFTsvYIw4DwvO7cZSOQhFihEHtQRHbWd8b95GGqwRjh2yN1HzMsgOrptQC0Op038S4pSeFRGINj1HJbj320ctbLJoP9GBROpMJ71GR2V2RnTunxcMmlx4yVYEIHS1JijcDQ2BlG8ZikIVfeKqoipzd592Sq2JUuhPgL2MMeI8onHFGRJqUJv3elXev4qcXSqclQtGyTOewB5YZBHrqj3Pg134RmiOxapNWe0HaLPjsrflviJuZepU1ETh6epjcbZi4vl32MCQF/v1nhezoP4BLijJWstKmgmwXuFoDra6niUPcU97e1o5OnFkySlV9IcH+vrAo+sNGt0ztRen3zU+cab8ff0CnH1Me23Tzf3H6gkZCaHlol0xB5u75+5QSpgNAlgQfcbpZVqYsRPpCf61qJixfy/QPBydAWQ5uwG6KzgGTffAmAj4WOa3/n+19jsPNS6h0+VNEgRnoGfIzckQ/hrOV9wPIJ5oJ99SNYLnQ4SLl3JqqttryEHyBSRdYQ7wn3pb9oPfq58/47f/NTPNZRAsemVEx+9udm25+AtEAtS3OB6sQgQ= on: - tags: true distributions: sdist bdist_wheel repo: bernardopires/django-tenant-schemas + branch: master From ab5c7088a0e7620c78e72cfe735dbb41bf386a5e Mon Sep 17 00:00:00 2001 From: Adam Ringwood Date: Wed, 30 Nov 2016 14:39:30 -0600 Subject: [PATCH 077/157] Patching default schema setting in. --- tenant_schemas/middleware.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 68b4530..20f6c16 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -34,12 +34,17 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): hostname = self.hostname_from_request(request) TenantModel = get_tenant_model() + try: request.tenant = TenantModel.objects.get(domain_url=hostname) - connection.set_tenant(request.tenant) except TenantModel.DoesNotExist: - raise self.TENANT_NOT_FOUND_EXCEPTION( - 'No tenant for hostname "%s"' % hostname) + if hasattr(settings, 'SCHEMA_DEFAULT'): + request.tenant = TenantModel.objects.get(schema_name=settings.SCHEMA_DEFAULT) + else: + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'No tenant for hostname "%s"' % hostname) + + connection.set_tenant(request.tenant) # Content type can no longer be cached as public and tenant schemas # have different models. If someone wants to change this, the cache From 1b797d5430f1803c0c1d498fe53cef88569aeeff Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Fri, 30 Dec 2016 17:14:52 +0100 Subject: [PATCH 078/157] Added tests for the SHEMA_DEFAULT setting in TenantMiddleware --- tenant_schemas/tests/test_routes.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 6a5bcb1..345a6f0 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.http import Http404 from django.test.client import RequestFactory from tenant_schemas.middleware import TenantMiddleware @@ -29,6 +30,9 @@ class RoutesTestCase(BaseTestCase): self.tenant = Tenant(domain_url=self.tenant_domain, schema_name='test') self.tenant.save(verbosity=BaseTestCase.get_verbosity()) + self.non_exisitant_domain = 'no-tenant.test.com' + self.non_exisitant_tenant = Tenant(domain_url=self.non_exisitant_domain, schema_name='no-tenant') + def test_tenant_routing(self): """ Request path should not be altered. @@ -56,3 +60,27 @@ class RoutesTestCase(BaseTestCase): # request.tenant should also have been set self.assertEquals(request.tenant, self.public_tenant) + + def test_non_exisitant_tenant_routing(self): + """ + Request path should not be altered. + """ + request = self.factory.get('/any/request/', + HTTP_HOST=self.non_exisitant_tenant.domain_url) + + self.assertRaises(self.tm.TENANT_NOT_FOUND_EXCEPTION, self.tm.process_request, request) + + def test_non_existant_tenant_to_default_schema_routing(self): + """ + Request path should not be altered. + """ + settings.SCHEMA_DEFAULT = self.tenant.schema_name + request_url = '/any/request/' + request = self.factory.get('/any/request/', + HTTP_HOST=self.tenant_domain) + self.tm.process_request(request) + + self.assertEquals(request.path_info, request_url) + + # request.tenant should also have been set + self.assertEquals(request.tenant, self.tenant) From 1b3cd3e79b78e19ef98eb700b36eb9fe6b54b0be Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Fri, 30 Dec 2016 19:39:03 +0100 Subject: [PATCH 079/157] Changed setting name to DEFAULT_SCHEMA to be more in line with django. --- tenant_schemas/middleware.py | 4 ++-- tenant_schemas/tests/test_routes.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 20f6c16..a164468 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -38,8 +38,8 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): try: request.tenant = TenantModel.objects.get(domain_url=hostname) except TenantModel.DoesNotExist: - if hasattr(settings, 'SCHEMA_DEFAULT'): - request.tenant = TenantModel.objects.get(schema_name=settings.SCHEMA_DEFAULT) + if hasattr(settings, 'DEFAULT_SCHEMA'): + request.tenant = TenantModel.objects.get(schema_name=settings.DEFAULT_SCHEMA) else: raise self.TENANT_NOT_FOUND_EXCEPTION( 'No tenant for hostname "%s"' % hostname) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 345a6f0..3b36c12 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -70,11 +70,11 @@ class RoutesTestCase(BaseTestCase): self.assertRaises(self.tm.TENANT_NOT_FOUND_EXCEPTION, self.tm.process_request, request) - def test_non_existant_tenant_to_default_schema_routing(self): + def test_non_existent_tenant_to_default_schema_routing(self): """ Request path should not be altered. """ - settings.SCHEMA_DEFAULT = self.tenant.schema_name + settings.DEFAULT_SCHEMA = self.tenant.schema_name request_url = '/any/request/' request = self.factory.get('/any/request/', HTTP_HOST=self.tenant_domain) From 021e66723195462beba4e1b3f43dc16e3d380bd5 Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Fri, 30 Dec 2016 19:39:20 +0100 Subject: [PATCH 080/157] Added Default_Schema settings to the install.rst documentation --- docs/install.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/install.rst b/docs/install.rst index 80c2e5a..b1edd5c 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -159,6 +159,16 @@ Optional Settings The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. +.. attribute:: DEFAULT_SCHEMA + + :Default: ``None`` + + The schema which will be selected when a non existent domain name/tenant is used. Instead of raising a 404 you could redirect traffic to your public schema. + + .. code-block:: python + + DEFAULT_SCHEMA = 'public' + .. attribute:: TENANT_CREATION_FAKES_MIGRATIONS :Default: ``'True'`` From 36d4966a03f09eb06e628a085db538a281259fde Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Wed, 25 Jan 2017 14:32:51 +0100 Subject: [PATCH 081/157] Renamed DEFAULT_SCHEMA to DEFAULT_SCHEMA_NAME and added with.settings instead of overwriting settings in a test. --- docs/install.rst | 4 ++-- tenant_schemas/middleware.py | 6 ++++-- tenant_schemas/tests/test_routes.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index b1edd5c..1f73635 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -159,7 +159,7 @@ Optional Settings The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. -.. attribute:: DEFAULT_SCHEMA +.. attribute:: DEFAULT_SCHEMA_NAME :Default: ``None`` @@ -167,7 +167,7 @@ Optional Settings .. code-block:: python - DEFAULT_SCHEMA = 'public' + DEFAULT_SCHEMA_NAME = 'public' .. attribute:: TENANT_CREATION_FAKES_MIGRATIONS diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index a164468..03c2bc9 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -38,8 +38,10 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): try: request.tenant = TenantModel.objects.get(domain_url=hostname) except TenantModel.DoesNotExist: - if hasattr(settings, 'DEFAULT_SCHEMA'): - request.tenant = TenantModel.objects.get(schema_name=settings.DEFAULT_SCHEMA) + if hasattr(settings, 'DEFAULT_SCHEMA_NAME'): + request.tenant = TenantModel.objects.get( + schema_name=settings.DEFAULT_SCHEMA_NAME + ) else: raise self.TENANT_NOT_FOUND_EXCEPTION( 'No tenant for hostname "%s"' % hostname) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 3b36c12..819f64c 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -74,13 +74,13 @@ class RoutesTestCase(BaseTestCase): """ Request path should not be altered. """ - settings.DEFAULT_SCHEMA = self.tenant.schema_name - request_url = '/any/request/' - request = self.factory.get('/any/request/', - HTTP_HOST=self.tenant_domain) - self.tm.process_request(request) + with self.settings(DEFAULT_SCHEMA_NAME='test'): + request_url = '/any/request/' + request = self.factory.get('/any/request/', + HTTP_HOST=self.tenant_domain) + self.tm.process_request(request) - self.assertEquals(request.path_info, request_url) + self.assertEquals(request.path_info, request_url) - # request.tenant should also have been set - self.assertEquals(request.tenant, self.tenant) + # request.tenant should also have been set + self.assertEquals(request.tenant, self.tenant) From 5f169a276c824ca4a92972a367331c2eb726c746 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 00:52:31 +1100 Subject: [PATCH 082/157] Alternative implementation without introducing new settings. Create a new subclass of TenantMiddleware which handles the fallback scenario. --- docs/install.rst | 25 ++++++++++---------- tenant_schemas/middleware.py | 44 ++++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 1f73635..da7ebc9 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -32,7 +32,18 @@ Add `tenant_schemas.routers.TenantSyncRouter` to your `DATABASE_ROUTERS` setting Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the top of ``MIDDLEWARE_CLASSES``, so that each request can be set to use the correct schema. -If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. +If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. + +If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. + +If you'd like to serve the public tenant for unrecognised hostnames instead, use ``tenant_schemas.middleware.DefaultTenantMiddleware``. To use an tenant other than the public tenant, create a subclass and register it instead. + +.. code-block:: python + + from tenant_schemas.middleware import DefaultTenantMiddleware + + class MyDefaultTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'default' .. code-block:: python @@ -156,18 +167,8 @@ Optional Settings .. attribute:: PUBLIC_SCHEMA_NAME :Default: ``'public'`` - + The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. - -.. attribute:: DEFAULT_SCHEMA_NAME - - :Default: ``None`` - - The schema which will be selected when a non existent domain name/tenant is used. Instead of raising a 404 you could redirect traffic to your public schema. - - .. code-block:: python - - DEFAULT_SCHEMA_NAME = 'public' .. attribute:: TENANT_CREATION_FAKES_MIGRATIONS diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 03c2bc9..585bd5e 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -27,6 +27,9 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): """ return remove_www(request.get_host().split(':')[0]).lower() + def get_tenant(self, model, hostname, request): + return model.objects.get(domain_url=hostname) + def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. @@ -36,17 +39,11 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): TenantModel = get_tenant_model() try: - request.tenant = TenantModel.objects.get(domain_url=hostname) + request.tenant = self.get_tenant(TenantModel, hostname, request) + connection.set_tenant(request.tenant) except TenantModel.DoesNotExist: - if hasattr(settings, 'DEFAULT_SCHEMA_NAME'): - request.tenant = TenantModel.objects.get( - schema_name=settings.DEFAULT_SCHEMA_NAME - ) - else: - raise self.TENANT_NOT_FOUND_EXCEPTION( - 'No tenant for hostname "%s"' % hostname) - - connection.set_tenant(request.tenant) + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'No tenant for hostname "%s"' % hostname) # Content type can no longer be cached as public and tenant schemas # have different models. If someone wants to change this, the cache @@ -73,3 +70,30 @@ class SuspiciousTenantMiddleware(TenantMiddleware): discussion on this middleware. """ TENANT_NOT_FOUND_EXCEPTION = DisallowedHost + + +class DefaultTenantMiddleware(SuspiciousTenantMiddleware): + """ + Extend the SuspiciousTenantMiddleware in scenario where you want to + configure a tenant to be served if the hostname does not match any of the + existing tenants. + + Subclass and override DEFAULT_SCHEMA_NAME to use a schema other than the + public schema. + + class MyTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'default' + """ + DEFAULT_SCHEMA_NAME = None + + def get_tenant(self, model, hostname, request): + if self.DEFAULT_SCHEMA_NAME is None: + schema_name = get_public_schema_name() + else: + schema_name = self.DEFAULT_SCHEMA_NAME + + try: + super(DefaultTenantMiddleware, self).get_tenant( + model, hostname, request) + except model.DoesNotExist: + return model.objects.get(schema_name=schema_name) From 88156be136f55b93e09e01577ce6cf7c284dc2dc Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Wed, 25 Jan 2017 15:08:11 +0100 Subject: [PATCH 083/157] Added DefaultSchemaTenantMiddleware and created a method for get_tenant so it can be easily overwritten. Updated tests for the new middleware class --- tenant_schemas/middleware.py | 37 +++++++++++++++----- tenant_schemas/tests/test_routes.py | 52 ++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 03c2bc9..b2acec0 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -7,6 +7,7 @@ from django.db import connection from django.http import Http404 from tenant_schemas.utils import (get_tenant_model, remove_www, get_public_schema_name) + if django.VERSION >= (1, 10, 0): MIDDLEWARE_MIXIN = django.utils.deprecation.MiddlewareMixin else: @@ -27,6 +28,9 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): """ return remove_www(request.get_host().split(':')[0]).lower() + def get_tenant(self, tenant_model, hostname, request): + return tenant_model.objects.get(domain_url=hostname) + def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. @@ -36,15 +40,11 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): TenantModel = get_tenant_model() try: - request.tenant = TenantModel.objects.get(domain_url=hostname) + request.tenant = self.get_tenant(TenantModel, hostname, request) except TenantModel.DoesNotExist: - if hasattr(settings, 'DEFAULT_SCHEMA_NAME'): - request.tenant = TenantModel.objects.get( - schema_name=settings.DEFAULT_SCHEMA_NAME - ) - else: - raise self.TENANT_NOT_FOUND_EXCEPTION( - 'No tenant for hostname "%s"' % hostname) + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'No tenant for hostname "%s"' % hostname + ) connection.set_tenant(request.tenant) @@ -73,3 +73,24 @@ class SuspiciousTenantMiddleware(TenantMiddleware): discussion on this middleware. """ TENANT_NOT_FOUND_EXCEPTION = DisallowedHost + + +class DefaultSchemaTenantMiddleware(TenantMiddleware): + """ + This middleware makes it possible for you to set a default schema to be + selected when no schema could be determined from the hostname. + """ + + def get_tenant(self, TenantModel, hostname, request): + try: + return super(DefaultSchemaTenantMiddleware, self).get_tenant(TenantModel, hostname, request) + except TenantModel.DoesNotExist: + if hasattr(settings, 'DEFAULT_SCHEMA_NAME'): + return TenantModel.objects.get( + schema_name=settings.DEFAULT_SCHEMA_NAME + ) + else: + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'DEFAULT_SCHEMA_NAME was not set but ' + 'DefaultSchemaTenantMiddleware is being used.' + ) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 819f64c..c20f20e 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -1,8 +1,8 @@ from django.conf import settings -from django.http import Http404 from django.test.client import RequestFactory -from tenant_schemas.middleware import TenantMiddleware +from tenant_schemas.middleware import ( + TenantMiddleware, DefaultSchemaTenantMiddleware) from tenant_schemas.tests.models import Tenant from tenant_schemas.tests.testcases import BaseTestCase from tenant_schemas.utils import get_public_schema_name @@ -70,11 +70,13 @@ class RoutesTestCase(BaseTestCase): self.assertRaises(self.tm.TENANT_NOT_FOUND_EXCEPTION, self.tm.process_request, request) - def test_non_existent_tenant_to_default_schema_routing(self): + def test_existent_tenant_to_schema_routing(self): """ Request path should not be altered. """ - with self.settings(DEFAULT_SCHEMA_NAME='test'): + with self.settings(DEFAULT_SCHEMA_NAME=self.non_exisitant_tenant.schema_name): + self.tm = DefaultSchemaTenantMiddleware() + request_url = '/any/request/' request = self.factory.get('/any/request/', HTTP_HOST=self.tenant_domain) @@ -84,3 +86,45 @@ class RoutesTestCase(BaseTestCase): # request.tenant should also have been set self.assertEquals(request.tenant, self.tenant) + + def test_non_existent_tenant_to_default_schema_routing(self): + """ + Request path should not be altered. + """ + with self.settings(DEFAULT_SCHEMA_NAME=self.tenant.schema_name): + self.tm = DefaultSchemaTenantMiddleware() + + request_url = '/any/request/' + request = self.factory.get('/any/request/', + HTTP_HOST=self.non_exisitant_domain) + self.tm.process_request(request) + + self.assertEquals(request.path_info, request_url) + + # request.tenant should also have been set + self.assertEquals(request.tenant, self.tenant) + + def test_non_existent_tenant_to_non_existing_default_schema_routing(self): + """ + Request path should not be altered. + """ + with self.settings(DEFAULT_SCHEMA_NAME=self.non_exisitant_tenant.schema_name): + self.tm = DefaultSchemaTenantMiddleware() + + request = self.factory.get('/any/request/', + HTTP_HOST=self.non_exisitant_domain) + + self.assertRaises(self.tm.TENANT_NOT_FOUND_EXCEPTION, + self.tm.process_request, request) + + def test_non_existent_tenant_default_schema_not_set_routing(self): + """ + Request path should not be altered. + """ + self.tm = DefaultSchemaTenantMiddleware() + + request = self.factory.get('/any/request/', + HTTP_HOST=self.non_exisitant_domain) + + self.assertRaises(self.tm.TENANT_NOT_FOUND_EXCEPTION, + self.tm.process_request, request) From e1fda76f5090ecb0ecf0b0e32711610bdf6d5196 Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Wed, 25 Jan 2017 15:13:43 +0100 Subject: [PATCH 084/157] Updated documentation to reflect changes --- docs/install.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 1f73635..09cdff2 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -32,13 +32,15 @@ Add `tenant_schemas.routers.TenantSyncRouter` to your `DATABASE_ROUTERS` setting Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the top of ``MIDDLEWARE_CLASSES``, so that each request can be set to use the correct schema. -If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. +If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware`` middleware. +If instead of a HTTP 404 or HTTP 400 response you wish to use the DEFAULT_SCHEMA_NAME tenant you can use the ``tenant_schemas.middleware.DefaultSchemaTenantMiddleware`` middleware. The DEFAULT_SCHEMA_NAME attribute is required for this middleware. .. code-block:: python MIDDLEWARE_CLASSES = ( 'tenant_schemas.middleware.TenantMiddleware', # 'tenant_schemas.middleware.SuspiciousTenantMiddleware', + # 'tenant_schemas.middleware.DefaultSchemaTenantMiddleware', #... ) @@ -163,8 +165,8 @@ Optional Settings :Default: ``None`` - The schema which will be selected when a non existent domain name/tenant is used. Instead of raising a 404 you could redirect traffic to your public schema. - + The schema which will be selected when a non existent domain name/tenant is queried. Instead of raising a 404 you can redirect traffic to your public schema or any other schema. + **Note that this attribute is required and is only used by the `DefaultSchemaTenantMiddleware`**. .. code-block:: python DEFAULT_SCHEMA_NAME = 'public' From 71804949d78a27b49e8b739f95c29197646923cf Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 01:41:00 +1100 Subject: [PATCH 085/157] Update tests and fix bug in DefaultTenantMiddleware.get_tenant --- tenant_schemas/middleware.py | 2 +- tenant_schemas/tests/test_routes.py | 73 +++++++++++++---------------- 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 585bd5e..5be98bf 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -93,7 +93,7 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): schema_name = self.DEFAULT_SCHEMA_NAME try: - super(DefaultTenantMiddleware, self).get_tenant( + return super(DefaultTenantMiddleware, self).get_tenant( model, hostname, request) except model.DoesNotExist: return model.objects.get(schema_name=schema_name) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 819f64c..9f5797a 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -1,13 +1,16 @@ from django.conf import settings from django.http import Http404 from django.test.client import RequestFactory - -from tenant_schemas.middleware import TenantMiddleware +from tenant_schemas.middleware import DefaultTenantMiddleware, TenantMiddleware from tenant_schemas.tests.models import Tenant from tenant_schemas.tests.testcases import BaseTestCase from tenant_schemas.utils import get_public_schema_name +class TestDefaultTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'test' + + class RoutesTestCase(BaseTestCase): @classmethod def setUpClass(cls): @@ -25,6 +28,7 @@ class RoutesTestCase(BaseTestCase): super(RoutesTestCase, self).setUp() self.factory = RequestFactory() self.tm = TenantMiddleware() + self.dtm = DefaultTenantMiddleware() self.tenant_domain = 'tenant.test.com' self.tenant = Tenant(domain_url=self.tenant_domain, schema_name='test') @@ -33,54 +37,41 @@ class RoutesTestCase(BaseTestCase): self.non_exisitant_domain = 'no-tenant.test.com' self.non_exisitant_tenant = Tenant(domain_url=self.non_exisitant_domain, schema_name='no-tenant') + self.url = '/any/path/' + def test_tenant_routing(self): - """ - Request path should not be altered. - """ - request_url = '/any/request/' - request = self.factory.get('/any/request/', - HTTP_HOST=self.tenant_domain) + request = self.factory.get( + self.url, HTTP_HOST=self.tenant_domain) self.tm.process_request(request) - - self.assertEquals(request.path_info, request_url) - - # request.tenant should also have been set + self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.tenant) def test_public_schema_routing(self): - """ - Request path should not be altered. - """ - request_url = '/any/request/' - request = self.factory.get('/any/request/', - HTTP_HOST=self.public_tenant.domain_url) + request = self.factory.get( + self.url, HTTP_HOST=self.public_tenant.domain_url) self.tm.process_request(request) - - self.assertEquals(request.path_info, request_url) - - # request.tenant should also have been set + self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.public_tenant) def test_non_exisitant_tenant_routing(self): - """ - Request path should not be altered. - """ - request = self.factory.get('/any/request/', - HTTP_HOST=self.non_exisitant_tenant.domain_url) - - self.assertRaises(self.tm.TENANT_NOT_FOUND_EXCEPTION, self.tm.process_request, request) + """Raise 404 for unrecognised hostnames.""" + request = self.factory.get( + self.url, HTTP_HOST=self.non_exisitant_tenant.domain_url) + self.assertRaises(Http404, self.tm.process_request, request) def test_non_existent_tenant_to_default_schema_routing(self): - """ - Request path should not be altered. - """ - with self.settings(DEFAULT_SCHEMA_NAME='test'): - request_url = '/any/request/' - request = self.factory.get('/any/request/', - HTTP_HOST=self.tenant_domain) - self.tm.process_request(request) + """Route unrecognised hostnames to the 'public' tenant.""" + request = self.factory.get( + self.url, HTTP_HOST=self.non_exisitant_tenant.domain_url) + self.dtm.process_request(request) + self.assertEquals(request.path_info, self.url) + self.assertEquals(request.tenant, self.public_tenant) - self.assertEquals(request.path_info, request_url) - - # request.tenant should also have been set - self.assertEquals(request.tenant, self.tenant) + def test_non_existent_tenant_custom_middleware(self): + """Route unrecognised hostnames to the 'test' tenant.""" + dtm = TestDefaultTenantMiddleware() + request = self.factory.get( + self.url, HTTP_HOST=self.non_exisitant_tenant.domain_url) + dtm.process_request(request) + self.assertEquals(request.path_info, self.url) + self.assertEquals(request.tenant, self.tenant) From b24faa0a73cfd1a986b3e1a65de5f879b1486486 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 01:44:55 +1100 Subject: [PATCH 086/157] Add middlewares to documentation (commented) --- docs/install.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install.rst b/docs/install.rst index da7ebc9..205aacd 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -50,6 +50,8 @@ If you'd like to serve the public tenant for unrecognised hostnames instead, use MIDDLEWARE_CLASSES = ( 'tenant_schemas.middleware.TenantMiddleware', # 'tenant_schemas.middleware.SuspiciousTenantMiddleware', + # 'tenant_schemas.middleware.DefaultTenantMiddleware', + # 'myproject.middleware.MyDefaultTenantMiddleware', #... ) From 8941fb1cd821550a09d8e75b81715446ce16ff5c Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 01:55:33 +1100 Subject: [PATCH 087/157] Fix spelling mistake in test cases --- tenant_schemas/tests/test_routes.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 9f5797a..dafc26d 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -34,8 +34,8 @@ class RoutesTestCase(BaseTestCase): self.tenant = Tenant(domain_url=self.tenant_domain, schema_name='test') self.tenant.save(verbosity=BaseTestCase.get_verbosity()) - self.non_exisitant_domain = 'no-tenant.test.com' - self.non_exisitant_tenant = Tenant(domain_url=self.non_exisitant_domain, schema_name='no-tenant') + self.non_existent_domain = 'no-tenant.test.com' + self.non_existent_tenant = Tenant(domain_url=self.non_existent_domain, schema_name='no-tenant') self.url = '/any/path/' @@ -53,16 +53,16 @@ class RoutesTestCase(BaseTestCase): self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.public_tenant) - def test_non_exisitant_tenant_routing(self): + def test_non_existent_tenant_routing(self): """Raise 404 for unrecognised hostnames.""" request = self.factory.get( - self.url, HTTP_HOST=self.non_exisitant_tenant.domain_url) + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) self.assertRaises(Http404, self.tm.process_request, request) def test_non_existent_tenant_to_default_schema_routing(self): """Route unrecognised hostnames to the 'public' tenant.""" request = self.factory.get( - self.url, HTTP_HOST=self.non_exisitant_tenant.domain_url) + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) self.dtm.process_request(request) self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.public_tenant) @@ -71,7 +71,7 @@ class RoutesTestCase(BaseTestCase): """Route unrecognised hostnames to the 'test' tenant.""" dtm = TestDefaultTenantMiddleware() request = self.factory.get( - self.url, HTTP_HOST=self.non_exisitant_tenant.domain_url) + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) dtm.process_request(request) self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.tenant) From 4c207fc6766696f7bcba09621b745798a01e3447 Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Wed, 25 Jan 2017 16:47:35 +0100 Subject: [PATCH 088/157] =?UTF-8?q?#430=20-=20Added=20an=20index=20check?= =?UTF-8?q?=20for=20=E2=80=98tenant=5Fschemas=E2=80=99=20in=20INSTALLED=5F?= =?UTF-8?q?APPS=20that=20checks=20whether=20it=20is=20placed=20before=20an?= =?UTF-8?q?y=20django=20app=20instead=20if=20it=20is=20placed=20first.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/install.rst | 2 +- tenant_schemas/apps.py | 3 ++- tenant_schemas/tests/test_apps.py | 15 ++++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 80c2e5a..a725909 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -82,7 +82,7 @@ To make use of shared and tenant-specific applications, there are two settings c .. code-block:: python SHARED_APPS = ( - 'tenant_schemas', # mandatory + 'tenant_schemas', # mandatory, should always be before any django app 'customers', # you must list the app where your tenant model resides in 'django.contrib.contenttypes', diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index 0572563..6e026be 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -42,7 +42,8 @@ def best_practice(app_configs, **kwargs): errors = [] - if INSTALLED_APPS[0] != 'tenant_schemas': + django_index = next(i for i, s in enumerate(INSTALLED_APPS) if 'django.' in s) + if INSTALLED_APPS.index('tenant_schemas') > django_index: errors.append( Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", obj="django.conf.settings", diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py index a139b3f..4e1772b 100644 --- a/tenant_schemas/tests/test_apps.py +++ b/tenant_schemas/tests/test_apps.py @@ -56,7 +56,7 @@ class AppConfigTests(TestCase): 'django.contrib.messages', 'django.contrib.staticfiles', ]) - def test_tenant_schemas_last_installed_apps(self): + def test_tenant_schemas_before_django_installed_apps(self): self.assertBestPractice([ Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", obj="django.conf.settings", @@ -65,6 +65,19 @@ class AppConfigTests(TestCase): "implementations."), ]) + @override_settings(INSTALLED_APPS=[ + 'dts_test_app', + 'customers', + 'tenant_schemas', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + ]) + def test_tenant_schemas_after_custom_apps_in_installed_apps(self): + self.assertBestPractice([]) + @override_settings(TENANT_APPS=()) def test_tenant_apps_empty(self): self.assertBestPractice([ From 50a9c696cf2aff044a3c173ef113500dd185dbf0 Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Wed, 25 Jan 2017 22:59:38 +0100 Subject: [PATCH 089/157] Changed wording on warning and refactored into startswith instead of in. --- tenant_schemas/apps.py | 5 +++-- tenant_schemas/tests/test_apps.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index 6e026be..c4283cf 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -42,10 +42,11 @@ def best_practice(app_configs, **kwargs): errors = [] - django_index = next(i for i, s in enumerate(INSTALLED_APPS) if 'django.' in s) + django_index = next(i for i, s in enumerate(INSTALLED_APPS) if s.startswith('django.')) if INSTALLED_APPS.index('tenant_schemas') > django_index: errors.append( - Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", + Warning("You should put 'tenant_schemas' before any django " + "core applications in INSTALLED_APPS.", obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py index 4e1772b..a14abd7 100644 --- a/tenant_schemas/tests/test_apps.py +++ b/tenant_schemas/tests/test_apps.py @@ -58,7 +58,8 @@ class AppConfigTests(TestCase): ]) def test_tenant_schemas_before_django_installed_apps(self): self.assertBestPractice([ - Warning("You should put 'tenant_schemas' first in INSTALLED_APPS.", + Warning("You should put 'tenant_schemas' before any django " + "core applications in INSTALLED_APPS.", obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " From c77c13199b4134f82f92db731e65be870730c898 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 09:16:10 +1100 Subject: [PATCH 090/157] Fix typo in documentation --- docs/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index 205aacd..d2cf7d8 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -36,7 +36,7 @@ If the hostname in the request does not match a valid tenant ``domain_url``, a H If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. -If you'd like to serve the public tenant for unrecognised hostnames instead, use ``tenant_schemas.middleware.DefaultTenantMiddleware``. To use an tenant other than the public tenant, create a subclass and register it instead. +If you'd like to serve the public tenant for unrecognised hostnames instead, use ``tenant_schemas.middleware.DefaultTenantMiddleware``. To use a tenant other than the public tenant, create a subclass and register it instead. .. code-block:: python From 736c2bcfe07bdd87db4b4c6491401904dfa6fada Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 09:21:39 +1100 Subject: [PATCH 091/157] Update middleware.py --- tenant_schemas/middleware.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 8b017c2..43b4ac8 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -88,10 +88,9 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): DEFAULT_SCHEMA_NAME = None def get_tenant(self, model, hostname, request): - if self.DEFAULT_SCHEMA_NAME is None: + schema_name = self.DEFAULT_SCHEMA_NAME + if not schema_name: schema_name = get_public_schema_name() - else: - schema_name = self.DEFAULT_SCHEMA_NAME try: return super(DefaultTenantMiddleware, self).get_tenant( From 59d34fc7a9bf5647ebeb3347d2583d74af958dde Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 26 Jan 2017 14:34:00 +1100 Subject: [PATCH 092/157] Add test that deals with a missing default tenant --- tenant_schemas/tests/test_routes.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index b51e1c8..b847813 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -1,6 +1,7 @@ from django.conf import settings +from django.core.exceptions import DisallowedHost +from django.http import Http404 from django.test.client import RequestFactory - from tenant_schemas.middleware import DefaultTenantMiddleware, TenantMiddleware from tenant_schemas.tests.models import Tenant from tenant_schemas.tests.testcases import BaseTestCase @@ -11,6 +12,10 @@ class TestDefaultTenantMiddleware(DefaultTenantMiddleware): DEFAULT_SCHEMA_NAME = 'test' +class MissingDefaultTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'missing' + + class RoutesTestCase(BaseTestCase): @classmethod def setUpClass(cls): @@ -75,3 +80,10 @@ class RoutesTestCase(BaseTestCase): dtm.process_request(request) self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.tenant) + + def test_non_existent_tenant_and_default_custom_middleware(self): + """Route unrecognised hostnames to the 'missing' tenant.""" + dtm = MissingDefaultTenantMiddleware() + request = self.factory.get( + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) + self.assertRaises(DisallowedHost, dtm.process_request, request) From 7227047d9b6543aad77c37d54894bfb09fa432be Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Thu, 26 Jan 2017 08:57:15 +0100 Subject: [PATCH 093/157] Moved default schema_name determination to except to it is only triggered when the sought tenant is not found. --- tenant_schemas/middleware.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 43b4ac8..fa84094 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -88,12 +88,12 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): DEFAULT_SCHEMA_NAME = None def get_tenant(self, model, hostname, request): - schema_name = self.DEFAULT_SCHEMA_NAME - if not schema_name: - schema_name = get_public_schema_name() - try: return super(DefaultTenantMiddleware, self).get_tenant( model, hostname, request) except model.DoesNotExist: + schema_name = self.DEFAULT_SCHEMA_NAME + if not schema_name: + schema_name = get_public_schema_name() + return model.objects.get(schema_name=schema_name) From aef4292b9f74e29354e7ea057cade6c220e06762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Thu, 9 Feb 2017 21:59:31 +0100 Subject: [PATCH 094/157] Cleanup trailing spaces in documentation --- docs/install.rst | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index e30aed3..5f46dba 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -46,7 +46,7 @@ If you'd like to serve the public tenant for unrecognised hostnames instead, use DEFAULT_SCHEMA_NAME = 'default' .. code-block:: python - + MIDDLEWARE_CLASSES = ( 'tenant_schemas.middleware.TenantMiddleware', # 'tenant_schemas.middleware.SuspiciousTenantMiddleware', @@ -54,7 +54,7 @@ If you'd like to serve the public tenant for unrecognised hostnames instead, use # 'myproject.middleware.MyDefaultTenantMiddleware', #... ) - + Make sure you have ``django.template.context_processors.request`` (``django.core.context_processors.request`` if you're on Django 1.8) listed under ``TEMPLATE_CONTEXT_PROCESSORS`` else the tenant will not be available on ``request``. .. code-block:: python @@ -63,7 +63,7 @@ Make sure you have ``django.template.context_processors.request`` (``django.core 'django.core.context_processors.request', #... ) - + The Tenant Model ================ Now we have to create your tenant model. Your tenant model can contain whichever fields you want, however, you **must** inherit from ``TenantMixin``. This Mixin only has two fields (``domain_url`` and ``schema_name``) and both are required. Here's an example, suppose we have an app named ``customers`` and we want to create a model called ``Client``. @@ -72,15 +72,15 @@ Now we have to create your tenant model. Your tenant model can contain whichever from django.db import models from tenant_schemas.models import TenantMixin - + class Client(TenantMixin): name = models.CharField(max_length=100) paid_until = models.DateField() on_trial = models.BooleanField() created_on = models.DateField(auto_now_add=True) - + # default true, schema will be automatically created and synced when it is saved - auto_create_schema = True + auto_create_schema = True Once you have defined your model, don't forget to create the migrations for it or otherwise Django >= 1.9 will not create its table. Replace ``customers`` with your app name. @@ -93,27 +93,27 @@ Configure Tenant and Shared Applications To make use of shared and tenant-specific applications, there are two settings called ``SHARED_APPS`` and ``TENANT_APPS``. ``SHARED_APPS`` is a tuple of strings just like ``INSTALLED_APPS`` and should contain all apps that you want to be synced to ``public``. If ``SHARED_APPS`` is set, then these are the only apps that will be synced to your ``public`` schema! The same applies for ``TENANT_APPS``, it expects a tuple of strings where each string is an app. If set, only those applications will be synced to all your tenants. Here's a sample setting .. code-block:: python - + SHARED_APPS = ( 'tenant_schemas', # mandatory, should always be before any django app 'customers', # you must list the app where your tenant model resides in - + 'django.contrib.contenttypes', - + # everything below here is optional - 'django.contrib.auth', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', ) - + TENANT_APPS = ( 'django.contrib.contenttypes', # your tenant-specific apps 'myapp.hotels', - 'myapp.houses', + 'myapp.houses', ) INSTALLED_APPS = ( @@ -134,7 +134,7 @@ You also have to set where your tenant model is. .. code-block:: python TENANT_MODEL = "customers.Client" # app.Model - + Now run ``migrate_schemas --shared`` to create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command. .. code-block:: bash @@ -175,7 +175,7 @@ Optional Settings .. attribute:: TENANT_CREATION_FAKES_MIGRATIONS :Default: ``'True'`` - + Sets if the models will be synced directly to the last version and all migration subsequently faked. Useful in the cases where migrations can not be faked and need to be ran individually. Be aware that setting this to `False` may significantly slow down the process of creating tenants. Tenant View-Routing @@ -190,12 +190,12 @@ Tenant View-Routing .. code-block:: python PUBLIC_SCHEMA_URLCONF = 'myproject.urls_public' - - When requesting the view ``/login/`` from the public tenant (your main website), it will search for this path on ``PUBLIC_SCHEMA_URLCONF`` instead of ``ROOT_URLCONF``. + + When requesting the view ``/login/`` from the public tenant (your main website), it will search for this path on ``PUBLIC_SCHEMA_URLCONF`` instead of ``ROOT_URLCONF``. Separate projects for the main website and tenants (optional) ------------------------------------------------------------- -In some cases using the ``PUBLIC_SCHEMA_URLCONF`` can be difficult. For example, `Django CMS `_ takes some control over the default Django URL routing by using middlewares that do not play well with the tenants. Another example would be when some apps on the main website need different settings than the tenants website. In these cases it is much simpler if you just run the main website `example.com` as a separate application. +In some cases using the ``PUBLIC_SCHEMA_URLCONF`` can be difficult. For example, `Django CMS `_ takes some control over the default Django URL routing by using middlewares that do not play well with the tenants. Another example would be when some apps on the main website need different settings than the tenants website. In these cases it is much simpler if you just run the main website `example.com` as a separate application. If your projects are ran using a WSGI configuration, this can be done by creating a filed called ``wsgi_main_website.py`` in the same folder as ``wsgi.py``. @@ -246,7 +246,7 @@ Here's how you can configure your Apache server to route all subdomains to your Building Documentation ====================== -Documentation is available in ``docs`` and can be built into a number of +Documentation is available in ``docs`` and can be built into a number of formats using `Sphinx `_. To get started .. code-block:: bash From 80b148eb1bf24a2a4c7d7c4cf218203c997d9b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Thu, 9 Feb 2017 21:59:58 +0100 Subject: [PATCH 095/157] 'tenant_schemas' must be put at the top of INSTALLED_APPS --- docs/install.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index 5f46dba..26f18fc 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -117,6 +117,8 @@ To make use of shared and tenant-specific applications, there are two settings c ) INSTALLED_APPS = ( + 'tenant_schemas', # mandatory, should always be before any django app + 'customers', 'django.contrib.contenttypes', 'django.contrib.auth', @@ -126,7 +128,6 @@ To make use of shared and tenant-specific applications, there are two settings c 'django.contrib.admin', 'myapp.hotels', 'myapp.houses', - 'tenant_schemas', ) You also have to set where your tenant model is. From 3579735cb320a01163a80c8d50cab3f885e68a08 Mon Sep 17 00:00:00 2001 From: Alexander van Eck Date: Tue, 3 Jan 2017 17:34:06 +0100 Subject: [PATCH 096/157] #424: Added IF EXISTS to DROP SCHEMA queries --- tenant_schemas/models.py | 2 +- tenant_schemas/test/cases.py | 2 +- tenant_schemas/tests/testcases.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 7907ac7..c507fcf 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -67,7 +67,7 @@ class TenantMixin(models.Model): if schema_exists(self.schema_name) and (self.auto_drop_schema or force_drop): cursor = connection.cursor() - cursor.execute('DROP SCHEMA %s CASCADE' % self.schema_name) + cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE' % self.schema_name) super(TenantMixin, self).delete(*args, **kwargs) diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index a6b2e29..beb489c 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -22,7 +22,7 @@ class TenantTestCase(TestCase): cls.tenant.delete() cursor = connection.cursor() - cursor.execute('DROP SCHEMA test CASCADE') + cursor.execute('DROP SCHEMA IF EXISTS test CASCADE') @classmethod def sync_shared(cls): diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 75a923a..18531da 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -25,7 +25,7 @@ class BaseTestCase(TestCase): # a blank public schema for this set of tests. connection.set_schema_to_public() cursor = connection.cursor() - cursor.execute('DROP SCHEMA %s CASCADE; CREATE SCHEMA %s;' + cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE; CREATE SCHEMA %s;' % (get_public_schema_name(), get_public_schema_name())) super(BaseTestCase, cls).setUpClass() From 8b7201f9b9305d3f1c6a508aad840c75a00cb668 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Tue, 21 Feb 2017 21:02:24 +1100 Subject: [PATCH 097/157] Update README to remove non-ASCII characters. Refs #434. --- README.rst | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/README.rst b/README.rst index 713392b..e02f950 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ Software-as-a-Service website. Django provides currently no simple way to support multiple tenants using the same project instance, even when only the data is different. -Because we don’t want you running many copies of your project, you’ll be +Because we don't want you running many copies of your project, you'll be able to have: - Multiple customers running on the same instance @@ -20,10 +20,10 @@ What are schemas ---------------- A schema can be seen as a directory in an operating system, each -directory (schema) with it’s own set of files (tables and objects). This +directory (schema) with it's own set of files (tables and objects). This allows the same table name and objects to be used in different schemas without conflict. For an accurate description on schemas, see -`PostgreSQL’s official documentation on schemas`_. +`PostgreSQL's official documentation on schemas`_. Why schemas ----------- @@ -31,7 +31,7 @@ Why schemas There are typically three solutions for solving the multitenancy problem. -1. Isolated Approach: Separate Databases. Each tenant has it’s own +1. Isolated Approach: Separate Databases. Each tenant has it's own database. 2. Semi Isolated Approach: Shared Database, Separate Schemas. One @@ -48,8 +48,8 @@ represents the ideal compromise between simplicity and performance. multitenancy. Plus, you only manage one database. - Performance: make use of shared connections, buffers and memory. -Each solution has it’s up and down sides, for a more in-depth -discussion, see Microsoft’s excellent article on `Multi-Tenant Data +Each solution has it's up and down sides, for a more in-depth +discussion, see Microsoft's excellent article on `Multi-Tenant Data Architecture`_. How it works @@ -58,11 +58,11 @@ How it works Tenants are identified via their host name (i.e tenant.domain.com). This information is stored on a table on the ``public`` schema. Whenever a request is made, the host name is used to match a tenant in the -database. If there’s a match, the search path is updated to use this -tenant’s schema. So from now on all queries will take place at the -tenant’s schema. For example, suppose you have a tenant ``customer`` at +database. If there's a match, the search path is updated to use this +tenant's schema. So from now on all queries will take place at the +tenant's schema. For example, suppose you have a tenant ``customer`` at http://customer.example.com. Any request incoming at -``customer.example.com`` will automatically use ``customer``\ ’s schema +``customer.example.com`` will automatically use ``customer``\ 's schema and make the tenant available at the request. If no tenant is found, a 404 error is raised. This also means you should have a tenant for your main domain, typically using the ``public`` schema. For more information @@ -94,7 +94,7 @@ the host name to identify which view to serve. Magic ~~~~~ -Everyone loves magic! You’ll be able to have all this barely having to +Everyone loves magic! You'll be able to have all this barely having to change your code! Setup & Documentation @@ -113,7 +113,7 @@ Your ``DATABASE_ENGINE`` setting needs to be changed to 'ENGINE': 'tenant_schemas.postgresql_backend', # .. } - } + } Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the top of ``MIDDLEWARE_CLASSES``, so that each request can be set to use @@ -125,9 +125,9 @@ the correct schema. 'tenant_schemas.middleware.TenantMiddleware', #... ) - -Add ``tenant_schemas.routers.TenantSyncRouter`` to your `DATABASE_ROUTERS` -setting, so that the correct apps can be synced, depending on what's + +Add ``tenant_schemas.routers.TenantSyncRouter`` to your `DATABASE_ROUTERS` +setting, so that the correct apps can be synced, depending on what's being synced (shared or tenant). .. code-block:: python @@ -182,13 +182,13 @@ will automatically create and sync/migrate the schema. tenant.save() Any request made to ``tenant.my-domain.com`` will now automatically set -your PostgreSQL’s ``search_path`` to ``tenant1`` and ``public``, making +your PostgreSQL's ``search_path`` to ``tenant1`` and ``public``, making shared apps available too. This means that any call to the methods ``filter``, ``get``, ``save``, ``delete`` or any other function -involving a database connection will now be done at the tenant’s schema, -so you shouldn’t need to change anything at your views. +involving a database connection will now be done at the tenant's schema, +so you shouldn't need to change anything at your views. -You’re all set, but we have left key details outside of this short +You're all set, but we have left key details outside of this short tutorial, such as creating the public tenant and configuring shared and tenant specific apps. Complete instructions can be found at `django-tenant-schemas.readthedocs.io`_. @@ -197,7 +197,7 @@ tenant specific apps. Complete instructions can be found at .. _django: https://www.djangoproject.com/ .. _PostgreSQL schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html -.. _PostgreSQL’s official documentation on schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html +.. _PostgreSQL's official documentation on schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html .. _Multi-Tenant Data Architecture: http://msdn.microsoft.com/en-us/library/aa479086.aspx .. |PyPi version| image:: https://img.shields.io/pypi/v/django-tenant-schemas.svg From 2e789f71bc474abd3ca9e5a690779e54d75656e0 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Wed, 22 Feb 2017 20:08:37 +1100 Subject: [PATCH 098/157] TenantStorageMixin (#442) * Add a tenant aware storage backend. Using a mixin that can be combined with existing core and 3rd party storage backends which isolates user uploaded media by introducing a directory based on the schema_name (which shouldn't change after it's been created) and a symbolic link from the domain_url to the directory. In the reverse proxy, the domain_url can be parametrised to serve the content via the symbolic link. * WARN if project is not using a tenant aware storage backend. * Add collectstatic_schemas management command * Update the __all__ directive for tenant_schemas.storage * Update test project for storage mixins * Add documentation for DEFAULT_FILE_STORAGE changes --- docs/conf.py | 7 ++- docs/use.rst | 47 +++++++++++++++- dts_test_project/dts_test_project/settings.py | 5 ++ tenant_schemas/apps.py | 10 +++- .../commands/collectstatic_schemas.py | 5 ++ tenant_schemas/storage.py | 55 +++++++++++++++++++ tox.ini | 2 +- 7 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tenant_schemas/management/commands/collectstatic_schemas.py create mode 100644 tenant_schemas/storage.py diff --git a/docs/conf.py b/docs/conf.py index b07c2d4..3b032f3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,7 @@ import datetime # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -92,6 +92,11 @@ pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +intersphinx_mapping = { + 'django': ( + 'https://docs.djangoproject.com/en/1.10/', + 'https://docs.djangoproject.com/en/1.10/_objects/'), +} # -- Options for HTML output --------------------------------------------------- diff --git a/docs/use.rst b/docs/use.rst index 8db61d8..da4cf13 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -133,6 +133,49 @@ Prints to standard output a tab separated list of schema:domain_url values for e done +Storage +------- + +The :mod:`~django.core.files.storage` API will not isolate media per tenant. Your ``MEDIA_ROOT`` will be a shared space between all tenants. + +To avoid this you should configure a tenant aware storage backend - you will be warned if this is not the case. + +.. code-block:: python + + # settings.py + + MEDIA_ROOT = '/data/media' + MEDIA_URL = '/media/' + DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' + +We provide :class:`tenant_schemas.storage.TenantStorageMixin` which can be added to any third-party storage backend. + +In your reverse proxy configuration you will need to capture use a regular expression to identify the ``domain_url`` to serve content from the appropriate directory. + +.. code-block:: text + + # illustrative /etc/nginx/cond.d/tenant.conf + + upstream web { + server localhost:8080 fail_timeout=5s; + } + + server { + listen 80; + server_name ~^(www\.)?(.+)$; + + location / { + proxy_pass http://web; + proxy_redirect off; + proxy_set_header Host $host; + } + + location /media/ { + alias /data/media/$2/; + } + } + + Utils ----- @@ -214,6 +257,7 @@ The optional ``TenantContextFilter`` can be included in ``settings.LOGGING`` to .. code-block:: python # settings.py + LOGGING = { 'filters': { 'tenant_context': { @@ -247,7 +291,8 @@ The hook for ensuring the ``search_path`` is set properly happens inside the ``D .. code-block:: python - #in settings.py: + # settings.py: + TENANT_LIMIT_SET_CALLS = True When set, ``django-tenant-schemas`` will set the search path only once per request. The default is ``False``. diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index ae8c46c..58716a7 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -26,6 +26,8 @@ TEMPLATE_DEBUG = True ALLOWED_HOSTS = [] +DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' + # Application definition @@ -83,6 +85,7 @@ DATABASES = { 'USER': os.environ.get('PG_USER', 'postgres'), 'PASSWORD': os.environ.get('PG_PASSWORD', 'root'), 'HOST': os.environ.get('PG_HOST', 'localhost'), + 'PORT': int(os.environ.get('PG_PORT', '5432')), } } @@ -127,6 +130,8 @@ USE_TZ = True STATIC_URL = '/static/' +STATICFILES_STORAGE = 'tenant_schemas.storage.TenantStaticFilesStorage' + LOGGING = { 'version': 1, 'disable_existing_loggers': False, diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index c4283cf..d644a96 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -1,7 +1,8 @@ from django.apps import AppConfig, apps from django.conf import settings from django.core.checks import Critical, Error, Warning, register - +from django.core.files.storage import default_storage +from tenant_schemas.storage import TenantStorageMixin from tenant_schemas.utils import get_public_schema_name, get_tenant_model @@ -87,4 +88,11 @@ def best_practice(app_configs, **kwargs): Error("You have SHARED_APPS that are not in INSTALLED_APPS", hint=[a for a in settings.SHARED_APPS if a in delta])) + if not isinstance(default_storage, TenantStorageMixin): + errors.append(Warning( + "Your default storage engine is not tenant aware.", + hint="Set settings.DEFAULT_FILE_STORAGE to " + "'tenant_schemas.storage.TenantFileSystemStorage'", + )) + return errors diff --git a/tenant_schemas/management/commands/collectstatic_schemas.py b/tenant_schemas/management/commands/collectstatic_schemas.py new file mode 100644 index 0000000..c20d435 --- /dev/null +++ b/tenant_schemas/management/commands/collectstatic_schemas.py @@ -0,0 +1,5 @@ +from tenant_schemas.management.commands import BaseTenantCommand + + +class Command(BaseTenantCommand): + COMMAND_NAME = 'collectstatic' diff --git a/tenant_schemas/storage.py b/tenant_schemas/storage.py new file mode 100644 index 0000000..ed01fea --- /dev/null +++ b/tenant_schemas/storage.py @@ -0,0 +1,55 @@ +import os + +from django.core.exceptions import SuspiciousOperation +from django.utils._os import safe_join + +from django.db import connection + +from django.core.files.storage import FileSystemStorage +from django.contrib.staticfiles.storage import StaticFilesStorage + +__all__ = ( + 'TenantStorageMixin', + 'TenantFileSystemStorage', + 'TenantStaticFilesStorage', +) + + +class TenantStorageMixin(object): + """ + Mixin that can be combined with other Storage backends to colocate media + for all tenants in distinct subdirectories. + + Using rewriting rules at the reverse proxy we can determine which content + gets served up, while any code interactions will account for the multiple + tenancy of the project. + """ + def path(self, name): + """ + Look for files in subdirectory of MEDIA_ROOT using the tenant's + domain_url value as the specifier. + """ + if name is None: + name = '' + try: + location = safe_join(self.location, connection.tenant.domain_url) + except AttributeError: + location = self.location + try: + path = safe_join(location, name) + except ValueError: + raise SuspiciousOperation( + "Attempted access to '%s' denied." % name) + return os.path.normpath(path) + + +class TenantFileSystemStorage(TenantStorageMixin, FileSystemStorage): + """ + Implementation that extends core Django's FileSystemStorage. + """ + + +class TenantStaticFilesStorage(TenantStorageMixin, StaticFilesStorage): + """ + Implementation that extends core Django's StaticFilesStorage. + """ diff --git a/tox.ini b/tox.ini index 7f2d7c6..fc4f8c5 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ deps = changedir = dts_test_project -passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST +passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT commands = coverage run manage.py test --noinput {posargs:tenant_schemas} From b6d1b5afbc3a410b2528d760f6e578b413aa2163 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 23 Feb 2017 07:55:07 +1100 Subject: [PATCH 099/157] Add bumpversion configuration --- setup.cfg | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.cfg b/setup.cfg index 315d73c..ebbb313 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,9 @@ +[bumpversion] +current_version = 1.6.11 +commit = True +tag = True + [flake8] exclude = .tox,docs,build,migrations,__init__.py ignore = C901,E501,E731 + From 8f71ffed76c797a3a930cd2ea5a0e34009890b83 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Thu, 23 Feb 2017 07:56:52 +1100 Subject: [PATCH 100/157] =?UTF-8?q?Bump=20version:=201.6.11=20=E2=86=92=20?= =?UTF-8?q?1.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ebbb313..72e6b8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.6.11 +current_version = 1.7.0 commit = True tag = True From e51748612ffd2ec59bbfd52c54e92832e46e3a37 Mon Sep 17 00:00:00 2001 From: David Jean Louis Date: Tue, 28 Feb 2017 14:54:04 +0100 Subject: [PATCH 101/157] Make sure tenant delete() method is called, even when using django querysets bulk delete method (fixes issue #446). --- tenant_schemas/models.py | 21 +++++++++++++++++++- tenant_schemas/tests/test_tenants.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index c507fcf..63cef96 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -6,6 +6,24 @@ from tenant_schemas.signals import post_schema_sync from tenant_schemas.utils import get_public_schema_name, schema_exists +class TenantQueryset(models.QuerySet): + """ + QuerySet for instances that inherit from the TenantMixin. + """ + def delete(self): + """ + Make sure we call the delete method of each object in the queryset so + that safety checks and schema deletion (if requested) are executed + even when using bulk delete. + """ + counter, counter_dict = 0, {} + for obj in self: + current_counter, current_counter_dict = obj.delete() + counter += current_counter + counter_dict.update(current_counter_dict) + return counter, counter_dict + + class TenantMixin(models.Model): """ All tenant models must inherit this class. @@ -27,6 +45,7 @@ class TenantMixin(models.Model): domain_url = models.CharField(max_length=128, unique=True) schema_name = models.CharField(max_length=63, unique=True, validators=[_check_schema_name]) + objects = TenantQueryset.as_manager() class Meta: abstract = True @@ -69,7 +88,7 @@ class TenantMixin(models.Model): cursor = connection.cursor() cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE' % self.schema_name) - super(TenantMixin, self).delete(*args, **kwargs) + return super(TenantMixin, self).delete(*args, **kwargs) def create_schema(self, check_if_exists=False, sync_schema=True, verbosity=1): diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 5dd27e7..2bf26d8 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -99,6 +99,35 @@ class TenantDataAndSettingsTest(BaseTestCase): self.assertFalse(schema_exists(tenant.schema_name)) Tenant.auto_drop_schema = False + def test_auto_drop_schema_bulk_delete(self): + """ + When bulk deleting tenants, it should also drop the schemas of + tenants that have auto_drop_schema set to True. + """ + Tenant.auto_drop_schema = True + schemas = ['auto_drop_schema1', 'auto_drop_schema2'] + for schema in schemas: + self.assertFalse(schema_exists(schema)) + tenant = Tenant( + domain_url='%s.test.com' % schema, + schema_name=schema + ) + tenant.save(verbosity=BaseTestCase.get_verbosity()) + self.assertTrue(schema_exists(tenant.schema_name)) + + # Force pending trigger events to be executed + cursor = connection.cursor() + cursor.execute('SET CONSTRAINTS ALL IMMEDIATE') + + # get a queryset of our 2 tenants and do a bulk delete + Tenant.objects.filter(schema_name__in=schemas).delete() + + # verify that the schemas where deleted + for schema in schemas: + self.assertFalse(schema_exists(schema)) + + Tenant.auto_drop_schema = False + def test_switching_search_path(self): tenant1 = Tenant(domain_url='something.test.com', schema_name='tenant1') From a7b3805f11908fcaddf5d4b73fc34da0af21c4f3 Mon Sep 17 00:00:00 2001 From: David Jean Louis Date: Tue, 28 Feb 2017 15:07:31 +0100 Subject: [PATCH 102/157] Fix compatibility issue with django < 1.9 --- tenant_schemas/models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index 63cef96..d51aa34 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -18,10 +18,13 @@ class TenantQueryset(models.QuerySet): """ counter, counter_dict = 0, {} for obj in self: - current_counter, current_counter_dict = obj.delete() - counter += current_counter - counter_dict.update(current_counter_dict) - return counter, counter_dict + result = obj.delete() + if result is not None: + current_counter, current_counter_dict = result + counter += current_counter + counter_dict.update(current_counter_dict) + if counter: + return counter, counter_dict class TenantMixin(models.Model): From 3223661559bbef7f1647a667e07cc9cd693603c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Wed, 8 Mar 2017 18:04:23 +0000 Subject: [PATCH 103/157] Add BaseTenantMiddleware for easier custom middleware support --- tenant_schemas/middleware.py | 75 +++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index fa84094..646d0c8 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -13,38 +13,39 @@ if django.VERSION >= (1, 10, 0): else: MIDDLEWARE_MIXIN = object +""" +These middlewares should be placed at the very top of the middleware stack. +Selects the proper database schema using request information. Can fail in +various ways which is better than corrupting or revealing data. + +Extend BaseTenantMiddleware for a custom tenant selection strategy, +such as inspecting the header, or extracting it from some OAuth token. +""" + +class BaseTenantMiddleware(MIDDLEWARE_MIXIN): -class TenantMiddleware(MIDDLEWARE_MIXIN): """ - This middleware should be placed at the very top of the middleware stack. - Selects the proper database schema using the request host. Can fail in - various ways which is better than corrupting or revealing data. + Subclass and override this to achieve desired behaviour. Given a + request, return the tenant to use. Tenant should be an instance + of TENANT_MODEL. """ - TENANT_NOT_FOUND_EXCEPTION = Http404 + def get_tenant(self, request): + raise Exception("You have not implemented get_tenant. Don't know which tenant to use!") - def hostname_from_request(self, request): - """ Extracts hostname from request. Used for custom requests filtering. - By default removes the request's port and common prefixes. - """ - return remove_www(request.get_host().split(':')[0]).lower() - - def get_tenant(self, model, hostname, request): - return model.objects.get(domain_url=hostname) def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. connection.set_schema_to_public() - hostname = self.hostname_from_request(request) - TenantModel = get_tenant_model() + # get_tenant must be implemented by extending this class. + tenant = self.get_tenant(request) - try: - request.tenant = self.get_tenant(TenantModel, hostname, request) - connection.set_tenant(request.tenant) - except TenantModel.DoesNotExist: - raise self.TENANT_NOT_FOUND_EXCEPTION( - 'No tenant for hostname "%s"' % hostname) + if tenant is None: + raise Exception("Trying to set current tenant to None!") + + request.tenant = tenant + connection.set_tenant(request.tenant) # Content type can no longer be cached as public and tenant schemas # have different models. If someone wants to change this, the cache @@ -59,6 +60,28 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF') and request.tenant.schema_name == get_public_schema_name(): request.urlconf = settings.PUBLIC_SCHEMA_URLCONF +class TenantMiddleware(BaseTenantMiddleware): + """ + Selects the proper database schema using the request host. E.g. . + """ + TENANT_NOT_FOUND_EXCEPTION = Http404 + + def hostname_from_request(self, request): + """ Extracts hostname from request. Used for custom requests filtering. + By default removes the request's port and common prefixes. + """ + return remove_www(request.get_host().split(':')[0]).lower() + + def get_tenant(self, request): + hostname = self.hostname_from_request(request) + + TenantModel = get_tenant_model() + try: + return TenantModel.objects.get(domain_url=hostname) + except TenantModel.DoesNotExist: + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'No tenant for hostname "%s"' % hostname) + class SuspiciousTenantMiddleware(TenantMiddleware): """ @@ -87,13 +110,13 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): """ DEFAULT_SCHEMA_NAME = None - def get_tenant(self, model, hostname, request): + def get_tenant(self, request): try: - return super(DefaultTenantMiddleware, self).get_tenant( - model, hostname, request) - except model.DoesNotExist: + return super(DefaultTenantMiddleware, self).get_tenant(request) + except self.TENANT_NOT_FOUND_EXCEPTION: schema_name = self.DEFAULT_SCHEMA_NAME if not schema_name: schema_name = get_public_schema_name() - return model.objects.get(schema_name=schema_name) + TenantModel = get_tenant_model() + return TenantModel.objects.get(schema_name=schema_name) From 540f32cab6bf756a76887461a8432d49f09ef217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Wed, 8 Mar 2017 19:03:20 +0000 Subject: [PATCH 104/157] Raise NotImplementedError in get_tenant --- tenant_schemas/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 646d0c8..1b5c95b 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -30,7 +30,7 @@ class BaseTenantMiddleware(MIDDLEWARE_MIXIN): of TENANT_MODEL. """ def get_tenant(self, request): - raise Exception("You have not implemented get_tenant. Don't know which tenant to use!") + raise NotImplementedError def process_request(self, request): From f532cbacc9249274b6b4cff98dffa17b4c3d5b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Wed, 8 Mar 2017 20:15:55 +0000 Subject: [PATCH 105/157] Replicate old behavior where we raise self.TENANT_NOT_FOUND_EXCEPTION for DefaultTenantMiddleware --- tenant_schemas/middleware.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 1b5c95b..f99612b 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -113,10 +113,13 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): def get_tenant(self, request): try: return super(DefaultTenantMiddleware, self).get_tenant(request) - except self.TENANT_NOT_FOUND_EXCEPTION: + except self.TENANT_NOT_FOUND_EXCEPTION, e: schema_name = self.DEFAULT_SCHEMA_NAME if not schema_name: schema_name = get_public_schema_name() TenantModel = get_tenant_model() - return TenantModel.objects.get(schema_name=schema_name) + try: + return TenantModel.objects.get(schema_name=schema_name) + except TenantModel.DoesNotExist: + raise e # Raise the same exception (we don't have a tenant) From 06a1b9d570753818a18112855fc6c3c89923d0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Wed, 8 Mar 2017 20:19:07 +0000 Subject: [PATCH 106/157] Support Python 2.6+ --- tenant_schemas/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index f99612b..1e4e41f 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -113,7 +113,7 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): def get_tenant(self, request): try: return super(DefaultTenantMiddleware, self).get_tenant(request) - except self.TENANT_NOT_FOUND_EXCEPTION, e: + except self.TENANT_NOT_FOUND_EXCEPTION as e: schema_name = self.DEFAULT_SCHEMA_NAME if not schema_name: schema_name = get_public_schema_name() From d80ac3ace58446b27e2a437269d434b1b216b144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Thu, 9 Mar 2017 16:33:28 +0000 Subject: [PATCH 107/157] Make self.get_tenant backwards compatible. Handle exceptions within process_request --- tenant_schemas/middleware.py | 58 +++++++++++++++++------------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 1e4e41f..c77c5ea 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -23,26 +23,41 @@ such as inspecting the header, or extracting it from some OAuth token. """ class BaseTenantMiddleware(MIDDLEWARE_MIXIN): + TENANT_NOT_FOUND_EXCEPTION = Http404 """ Subclass and override this to achieve desired behaviour. Given a request, return the tenant to use. Tenant should be an instance - of TENANT_MODEL. + of TENANT_MODEL. We have three parameters for backwards compatibility + (the request would be enough). """ - def get_tenant(self, request): + def get_tenant(self, model, hostname, request): raise NotImplementedError + def hostname_from_request(self, request): + """ Extracts hostname from request. Used for custom requests filtering. + By default removes the request's port and common prefixes. + """ + return remove_www(request.get_host().split(':')[0]).lower() def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. connection.set_schema_to_public() - # get_tenant must be implemented by extending this class. - tenant = self.get_tenant(request) + hostname = self.hostname_from_request(request) + TenantModel = get_tenant_model() - if tenant is None: - raise Exception("Trying to set current tenant to None!") + try: + # get_tenant must be implemented by extending this class. + tenant = self.get_tenant(TenantModel, hostname, request) + assert isinstance(request.tenant, TenantModel) + except TenantModel.DoesNotExist: + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'No tenant for {!r}'.format(request.get_host())) + except AssertionError: + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'Invalid tenant {!r}'.format(request.tenant)) request.tenant = tenant connection.set_tenant(request.tenant) @@ -64,23 +79,9 @@ class TenantMiddleware(BaseTenantMiddleware): """ Selects the proper database schema using the request host. E.g. . """ - TENANT_NOT_FOUND_EXCEPTION = Http404 - def hostname_from_request(self, request): - """ Extracts hostname from request. Used for custom requests filtering. - By default removes the request's port and common prefixes. - """ - return remove_www(request.get_host().split(':')[0]).lower() - - def get_tenant(self, request): - hostname = self.hostname_from_request(request) - - TenantModel = get_tenant_model() - try: - return TenantModel.objects.get(domain_url=hostname) - except TenantModel.DoesNotExist: - raise self.TENANT_NOT_FOUND_EXCEPTION( - 'No tenant for hostname "%s"' % hostname) + def get_tenant(self, model, hostname, request): + return model.objects.get(domain_url=hostname) class SuspiciousTenantMiddleware(TenantMiddleware): @@ -110,16 +111,13 @@ class DefaultTenantMiddleware(SuspiciousTenantMiddleware): """ DEFAULT_SCHEMA_NAME = None - def get_tenant(self, request): + def get_tenant(self, model, hostname, request): try: - return super(DefaultTenantMiddleware, self).get_tenant(request) - except self.TENANT_NOT_FOUND_EXCEPTION as e: + return super(DefaultTenantMiddleware, self).get_tenant( + model, hostname, request) + except model.DoesNotExist: schema_name = self.DEFAULT_SCHEMA_NAME if not schema_name: schema_name = get_public_schema_name() - TenantModel = get_tenant_model() - try: - return TenantModel.objects.get(schema_name=schema_name) - except TenantModel.DoesNotExist: - raise e # Raise the same exception (we don't have a tenant) + return model.objects.get(schema_name=schema_name) From b0dc8bdb547452a8fdc4f76ca85550a7d7b6fa0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Thu, 9 Mar 2017 16:33:57 +0000 Subject: [PATCH 108/157] Add an advanced usage section to the documentation --- docs/advanced_usage.rst | 61 +++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 +- docs/index.rst | 3 +- docs/install.rst | 2 ++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 docs/advanced_usage.rst diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst new file mode 100644 index 0000000..85be259 --- /dev/null +++ b/docs/advanced_usage.rst @@ -0,0 +1,61 @@ +============== +Advanced Usage +============== + +Custom tenant strategies (custom middleware support) +==================================================== +By default, ``django-tenant-schemas``'s strategies for determining the correct tenant involve extracting it from the URL (e.g. ``mytenant.mydomain.com``). This is done through a middleware, typically ``TenantMiddleware``. + +In some situations, it might be useful to use **alternative tenant selection strategies**. For example, consider a website with a fixed URL. An approach for this website might be to pass the tenant through a special header, or to determine it in some other manner based on the request (e.g. using an OAuth token mapped to a tenant). ``django-tenant-schemas`` offer an **easily extensible way to provide your own middleware** with minimal code changes. + +To add custom tenant selection strategies, you need to **subclass the** ``BaseTenantMiddleware`` **class and implement its** ``get_tenant`` **method**. This method accepts the current ``request`` object through which you can determine the tenant to use. In addition, for backwards-compatibility reasons, the method also accepts the tenant model class (``TENANT_MODEL``) and the ``hostname`` of the current request. **You should return an instance of your** ``TENANT_MODEL`` **class** from this function. +After creating your middleware, you should make it the top-most middleware in your list. You should only have one subclass of ``BaseTenantMiddleware`` per project. + +Note that you might also wish to extend the other provided middleware classes, such as ``TenantMiddleware``. For example, you might want to chain several strategies together, and you could do so by subclassing the original strategies and manipulating the call to ``super``'s ``get_tenant``. + + +Example: Determine tenant from HTTP header +------------------------------------------ +Suppose you wanted to determine the current tenant based on a request header (``X-DTS-SCHEMA``). You might implement a simple middleware such as: + +.. code-block:: python + + class XHeaderTenantMiddleware(BaseTenantMiddleware): + """ + Determines tenant by the value of the ``X-DTS-SCHEMA`` HTTP header. + """ + def get_tenant(self, model, hostname, request): + schema_name = request.META.get('HTTP_X_DTS_SCHEMA', get_public_schema_name()) + return model.objects.get(schema_name=schema_name) + +Your application could now specify the tenant with the ``X-DTS-SCHEMA`` HTTP header. In scenarios where you are configuring individual tenant websites by yourself, each with its own ``nginx`` configuration to redirect to the right tenant, you could use a configuration such as the one below: + + +.. code-block:: nginx + + # /etc/nginx/conf.d/multitenant.conf + + upstream web { + server localhost:8000; + } + + server { + listen 80 default_server; + server_name _; + + location / { + proxy_pass http://web; + proxy_set_header Host $host; + } + } + + server { + listen 80; + server_name example.com www.example.com; + + location / { + proxy_pass http://web; + proxy_set_header Host $host; + proxy_set_header X-DTS-SCHEMA example; # triggers XHeaderTenantMiddleware + } + } diff --git a/docs/conf.py b/docs/conf.py index 3b032f3..be89624 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,7 @@ import datetime # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.autosectionlabel',] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index d47d9b4..e890259 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,9 +48,10 @@ Contents .. toctree:: :maxdepth: 2 - + install use + advanced_usage examples templates test diff --git a/docs/install.rst b/docs/install.rst index 26f18fc..4b64894 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -38,6 +38,8 @@ If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use t If you'd like to serve the public tenant for unrecognised hostnames instead, use ``tenant_schemas.middleware.DefaultTenantMiddleware``. To use a tenant other than the public tenant, create a subclass and register it instead. +If you'd like a different tenant selection technique (e.g. using an HTTP Header), you can define a custom middleware. See :ref:`Advanced Usage`. + .. code-block:: python from tenant_schemas.middleware import DefaultTenantMiddleware From 2630ecbee498fecb4cf62dac279111948acb9f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Thu, 9 Mar 2017 16:37:44 +0000 Subject: [PATCH 109/157] Fix assert --- tenant_schemas/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index c77c5ea..639e686 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -51,7 +51,7 @@ class BaseTenantMiddleware(MIDDLEWARE_MIXIN): try: # get_tenant must be implemented by extending this class. tenant = self.get_tenant(TenantModel, hostname, request) - assert isinstance(request.tenant, TenantModel) + assert isinstance(tenant, TenantModel) except TenantModel.DoesNotExist: raise self.TENANT_NOT_FOUND_EXCEPTION( 'No tenant for {!r}'.format(request.get_host())) From 323cfcd38a60d61a9ba9b96eb01fa4a9a2e30d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Fri, 10 Mar 2017 16:06:03 +0100 Subject: [PATCH 110/157] Add migration executors --- setup.py | 2 + .../management/commands/__init__.py | 3 + .../management/commands/migrate_schemas.py | 33 +++------- .../migration_executors/__init__.py | 15 +++++ tenant_schemas/migration_executors/base.py | 64 +++++++++++++++++++ .../migration_executors/parallel.py | 30 +++++++++ .../migration_executors/standard.py | 9 +++ 7 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 tenant_schemas/migration_executors/__init__.py create mode 100644 tenant_schemas/migration_executors/base.py create mode 100644 tenant_schemas/migration_executors/parallel.py create mode 100644 tenant_schemas/migration_executors/standard.py diff --git a/setup.py b/setup.py index c201fd6..6b8613a 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python from os.path import exists + from version import get_git_version try: @@ -15,6 +16,7 @@ setup( author_email='carneiro.be@gmail.com', packages=[ 'tenant_schemas', + 'tenant_schemas.migration_executors', 'tenant_schemas.postgresql_backend', 'tenant_schemas.management', 'tenant_schemas.management.commands', diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 54c4711..c3c28ed 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -149,11 +149,14 @@ class SyncCommon(BaseCommand): help=('Database state will be brought to the state after that ' 'migration. Use the name "zero" to unapply all migrations.')) parser.add_argument("-s", "--schema", dest="schema_name") + parser.add_argument('--executor', action='store', dest='executor', default=None, + help='Executor for running migrations [standard (default)|parallel]') def handle(self, *args, **options): self.sync_tenant = options.get('tenant') self.sync_public = options.get('shared') self.schema_name = options.get('schema_name') + self.executor = options.get('executor') self.installed_apps = settings.INSTALLED_APPS self.args = args self.options = options diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 7878629..7e131c2 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -1,11 +1,10 @@ import django - -from django.conf import settings from django.core.management.commands.migrate import Command as MigrateCommand from django.db import connection from tenant_schemas.management.commands import SyncCommon -from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists +from tenant_schemas.migration_executors import get_executor +from tenant_schemas.utils import get_public_schema_name, get_tenant_model, schema_exists if django.VERSION >= (1, 9, 0): from django.db.migrations.exceptions import MigrationSchemaMissing @@ -34,35 +33,21 @@ class Command(SyncCommon): super(Command, self).handle(*args, **options) self.PUBLIC_SCHEMA_NAME = get_public_schema_name() + executor = get_executor(codename=self.executor)(self.args, self.options) + if self.sync_public and not self.schema_name: self.schema_name = self.PUBLIC_SCHEMA_NAME if self.sync_public: - self.run_migrations(self.schema_name, settings.SHARED_APPS) + executor.run_migrations(tenants=[self.schema_name]) if self.sync_tenant: if self.schema_name and self.schema_name != self.PUBLIC_SCHEMA_NAME: if not schema_exists(self.schema_name): raise MigrationSchemaMissing('Schema "{}" does not exist'.format( self.schema_name)) else: - self.run_migrations(self.schema_name, settings.TENANT_APPS) + tenants = [self.schema_name] else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) - for tenant in all_tenants: - self.run_migrations(tenant.schema_name, settings.TENANT_APPS) - - def run_migrations(self, schema_name, included_apps): - if int(self.options.get('verbosity', 1)) >= 1: - self._notice("=== Running migrate for schema %s" % schema_name) - - if not schema_exists(schema_name): - raise MigrationSchemaMissing('Schema "{}" does not exist'.format( - schema_name)) - - connection.set_schema(schema_name) - command = MigrateCommand() - command.execute(*self.args, **self.options) - connection.set_schema_to_public() - - def _notice(self, output): - self.stdout.write(self.style.NOTICE(output)) + tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()).values_list( + 'schema_name', flat=True) + executor.run_migrations(tenants=tenants) diff --git a/tenant_schemas/migration_executors/__init__.py b/tenant_schemas/migration_executors/__init__.py new file mode 100644 index 0000000..b83db18 --- /dev/null +++ b/tenant_schemas/migration_executors/__init__.py @@ -0,0 +1,15 @@ +import os + +from tenant_schemas.migration_executors.base import MigrationExecutor +from tenant_schemas.migration_executors.parallel import ParallelExecutor +from tenant_schemas.migration_executors.standard import StandardExecutor + + +def get_executor(codename=None): + codename = codename or os.environ.get('EXECUTOR', StandardExecutor.codename) + + for klass in MigrationExecutor.__subclasses__(): + if klass.codename == codename: + return klass + + raise NotImplementedError('No executor with codename %s' % codename) diff --git a/tenant_schemas/migration_executors/base.py b/tenant_schemas/migration_executors/base.py new file mode 100644 index 0000000..5ce6c59 --- /dev/null +++ b/tenant_schemas/migration_executors/base.py @@ -0,0 +1,64 @@ +import sys + +from django.core.management.commands.migrate import Command as MigrateCommand +from django.db import transaction + +from tenant_schemas.utils import get_public_schema_name + + +def run_migrations(args, options, executor_codename, schema_name, allow_atomic=True): + from django.core.management import color + from django.core.management.base import OutputWrapper + from django.db import connection + + style = color.color_style() + + def style_func(msg): + return '[%s:%s] %s' % ( + style.NOTICE(executor_codename), + style.NOTICE(schema_name), + msg + ) + + stdout = OutputWrapper(sys.stdout) + stdout.style_func = style_func + stderr = OutputWrapper(sys.stderr) + stderr.style_func = style_func + if int(options.get('verbosity', 1)) >= 1: + stdout.write(style.NOTICE("=== Running migrate for schema %s" % schema_name)) + + connection.set_schema(schema_name) + MigrateCommand(stdout=stdout, stderr=stderr).execute(*args, **options) + + try: + transaction.commit() + connection.close() + connection.connection = None + except transaction.TransactionManagementError: + if not allow_atomic: + raise + + # We are in atomic transaction, don't close connections + pass + + connection.set_schema_to_public() + + +class MigrationExecutor(object): + codename = None + + def __init__(self, args, options): + self.args = args + self.options = options + + def run_migrations(self, tenants): + public_schema_name = get_public_schema_name() + + if public_schema_name in tenants: + run_migrations(self.args, self.options, self.codename, public_schema_name) + tenants.pop(tenants.index(public_schema_name)) + + self.run_tenant_migrations(tenants) + + def run_tenant_migrations(self, tenant): + raise NotImplementedError diff --git a/tenant_schemas/migration_executors/parallel.py b/tenant_schemas/migration_executors/parallel.py new file mode 100644 index 0000000..9228378 --- /dev/null +++ b/tenant_schemas/migration_executors/parallel.py @@ -0,0 +1,30 @@ +import functools +import multiprocessing + +from django.conf import settings + +from tenant_schemas.migration_executors.base import MigrationExecutor, run_migrations + + +class ParallelExecutor(MigrationExecutor): + codename = 'parallel' + + def run_tenant_migrations(self, tenants): + if tenants: + processes = getattr(settings, 'TENANT_PARALLEL_MIGRATION_MAX_PROCESSES', 2) + chunks = getattr(settings, 'TENANT_PARALLEL_MIGRATION_CHUNKS', 2) + + from django.db import connection + + connection.close() + connection.connection = None + + run_migrations_p = functools.partial( + run_migrations, + self.args, + self.options, + self.codename, + allow_atomic=False + ) + p = multiprocessing.Pool(processes=processes) + p.map(run_migrations_p, tenants, chunks) diff --git a/tenant_schemas/migration_executors/standard.py b/tenant_schemas/migration_executors/standard.py new file mode 100644 index 0000000..be5eb2d --- /dev/null +++ b/tenant_schemas/migration_executors/standard.py @@ -0,0 +1,9 @@ +from tenant_schemas.migration_executors.base import MigrationExecutor, run_migrations + + +class StandardExecutor(MigrationExecutor): + codename = 'standard' + + def run_tenant_migrations(self, tenants): + for schema_name in tenants: + run_migrations(self.args, self.options, self.codename, schema_name) From c5c1f06d71a90617880c121daf3724c6136c60c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Fri, 10 Mar 2017 16:09:59 +0100 Subject: [PATCH 111/157] Added parallel migration executor to tox and travis --- .travis.yml | 25 +++++++++++++------------ tox.ini | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index d83ff55..b831b2c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,22 @@ +sudo: false language: python python: -- 2.7 -- 3.5 + - 2.7 + - 3.5 services: -- postgresql + - postgresql addons: postgresql: '9.4' -install: -- pip install tox -before_script: -- psql -c "CREATE DATABASE dts_test_project;" -U postgres -script: -- tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} +install: pip install -q tox-travis env: -- DJANGO=1.8 -- DJANGO=1.9 -- DJANGO=1.10 + - DJANGO=1.8 + - DJANGO=1.9 + - DJANGO=1.10 +matrix: + fast_finish: true +script: tox +before_script: psql -c "CREATE DATABASE dts_test_project;" -U postgres + deploy: provider: pypi user: bcarneiro diff --git a/tox.ini b/tox.ini index fc4f8c5..882e2ae 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,16 @@ [tox] -envlist = py{27,35}-dj{18,19,110} +envlist = py{27,35}-dj{18,19,110}-{standard,parallel} + +[travis:env] +DJANGO = + 1.8: dj18-{standard,parallel} + 1.9: dj19-{standard,parallel} + 1.10: dj110-{standard,parallel} [testenv] usedevelop = True -deps = +deps = coverage mock tblib @@ -16,6 +22,10 @@ changedir = dts_test_project passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT -commands = +setenv = + standard: MIGRATION_EXECUTOR=standard + parallel: MIGRATION_EXECUTOR=parallel + +commands = coverage run manage.py test --noinput {posargs:tenant_schemas} coverage report -m --include=../tenant_schemas/* From 45157bf459c5df0f11d8c327505bf2129e7ce2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20C=C3=A0naves?= Date: Fri, 10 Mar 2017 16:11:15 +0100 Subject: [PATCH 112/157] Update docs with new command info --- docs/use.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/use.rst b/docs/use.rst index da4cf13..a551485 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -95,6 +95,26 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H ``migrate_schemas`` raises an exception when an tenant schema is missing. +migrate_schemas in parallel +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once the number of tenants grow, migrating all the tenants can become a bottleneck. To speed up this process, you can run tenant migrations in parallel like this: + +.. code-block:: bash + + python manage.py migrate_schemas --executor=parallel + +In fact, you can write your own executor which will run tenant migrations in +any way you want, just take a look at ``tenant_schemas/migration_executors``. + +The ``parallel`` executor accepts the following settings: + +* ``TENANT_PARALLEL_MIGRATION_MAX_PROCESSES`` (default: 2) - maximum number of + processes for migration pool (this is to avoid exhausting the database + connection pool) +* ``TENANT_PARALLEL_MIGRATION_CHUNKS`` (default: 2) - number of migrations to be + sent at once to every worker + tenant_command ~~~~~~~~~~~~~~ From d2df0951a22529ba232d8b77c050a4245a3fd22a Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Thu, 16 Mar 2017 00:47:17 +0500 Subject: [PATCH 113/157] Django 1.10 Support --- tenant_schemas/template_loaders.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index c8c98ee..7cf1342 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -4,16 +4,31 @@ multi-tenant setting """ import hashlib +from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.template.base import TemplateDoesNotExist, Template -from django.utils.encoding import force_bytes -from django.utils._os import safe_join from django.db import connection +from django.template.base import Template from django.template.loaders.base import Loader as BaseLoader - +from django.utils._os import safe_join +from django.utils.encoding import force_bytes from tenant_schemas.postgresql_backend.base import FakeTenant +DJANGO_1_10 = DJANGO_VERSION[1] >= 10 + +if DJANGO_1_10: + from django.template import Origin, TemplateDoesNotExist + + + def make_origin(engine, name, loader, template_name, dirs): + return Origin(name=name, template_name=template_name, loader=loader) +else: + from django.template.base import TemplateDoesNotExist + + + def make_origin(engine, name, loader, template_name, dirs): + return engine.make_origin(name, loader, template_name, dirs) + class CachedLoader(BaseLoader): is_usable = True @@ -50,7 +65,7 @@ class CachedLoader(BaseLoader): except TemplateDoesNotExist: pass else: - origin = self.engine.make_origin(display_name, loader, name, dirs) + origin = make_origin(self.engine, display_name, loader, name, dirs) result = template, origin break self.find_template_cache[key] = result @@ -133,4 +148,5 @@ class FilesystemLoader(BaseLoader): else: error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." raise TemplateDoesNotExist(error_msg) + load_template_source.is_usable = True From 26692945c132b324c6e936849fa9b6e719d66167 Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Thu, 16 Mar 2017 00:52:07 +0500 Subject: [PATCH 114/157] Updated Django 1.10 Version Check --- tenant_schemas/template_loaders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 7cf1342..24c36da 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -14,7 +14,7 @@ from django.utils._os import safe_join from django.utils.encoding import force_bytes from tenant_schemas.postgresql_backend.base import FakeTenant -DJANGO_1_10 = DJANGO_VERSION[1] >= 10 +DJANGO_1_10 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 10 if DJANGO_1_10: from django.template import Origin, TemplateDoesNotExist From cf154c64638a231b8ff7e6150b5d96463762f9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Tue, 21 Mar 2017 14:59:42 +0100 Subject: [PATCH 115/157] Upgrade installation documentation for Django 1.8+ --- docs/install.rst | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 26f18fc..4eb0b64 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,6 +1,7 @@ -================== +============ Installation -================== +============ + Assuming you have django installed, the first step is to install ``django-tenant-schemas``. .. code-block:: bash @@ -55,7 +56,26 @@ If you'd like to serve the public tenant for unrecognised hostnames instead, use #... ) -Make sure you have ``django.template.context_processors.request`` (``django.core.context_processors.request`` if you're on Django 1.8) listed under ``TEMPLATE_CONTEXT_PROCESSORS`` else the tenant will not be available on ``request``. +Make sure you have ``django.template.context_processors.request`` listed under ``context_processors`` (Django 1.8+): + +.. code-block:: python + + TEMPLATES = [ + { + 'BACKEND': # ... + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + # ... + 'django.template.context_processors.request', + # ... + ] + } + } + ] + +If you are using an old version of Django (before v1.8), the following setting must be set in ``TEMPLATE_CONTEXT_PROCESSORS``: .. code-block:: python From 8f1480ad6251c3d49ac210f2e602de2aab34bb87 Mon Sep 17 00:00:00 2001 From: viatrak Date: Fri, 24 Mar 2017 11:56:00 -0500 Subject: [PATCH 116/157] Add IDs to system checks. Can now be used in SILENCED_SYSTEM_CHECKS --- tenant_schemas/apps.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index d644a96..b7f254b 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -51,12 +51,14 @@ def best_practice(app_configs, **kwargs): obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " - "implementations.")) + "implementations.", + id="tenant_schemas.W001")) if not settings.TENANT_APPS: errors.append( Error("TENANT_APPS is empty.", - hint="Maybe you don't need this app?")) + hint="Maybe you don't need this app?", + id="tenant_schemas.E001")) if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: @@ -74,25 +76,29 @@ def best_practice(app_configs, **kwargs): if not settings.SHARED_APPS: errors.append( - Warning("SHARED_APPS is empty.")) + Warning("SHARED_APPS is empty.", + id="tenant_schemas.W002")) if not set(settings.TENANT_APPS).issubset(INSTALLED_APPS): delta = set(settings.TENANT_APPS).difference(INSTALLED_APPS) errors.append( Error("You have TENANT_APPS that are not in INSTALLED_APPS", - hint=[a for a in settings.TENANT_APPS if a in delta])) + hint=[a for a in settings.TENANT_APPS if a in delta], + id="tenant_schemas.E002")) if not set(settings.SHARED_APPS).issubset(INSTALLED_APPS): delta = set(settings.SHARED_APPS).difference(INSTALLED_APPS) errors.append( Error("You have SHARED_APPS that are not in INSTALLED_APPS", - hint=[a for a in settings.SHARED_APPS if a in delta])) + hint=[a for a in settings.SHARED_APPS if a in delta], + id="tenant_schemas.E003")) if not isinstance(default_storage, TenantStorageMixin): errors.append(Warning( "Your default storage engine is not tenant aware.", hint="Set settings.DEFAULT_FILE_STORAGE to " "'tenant_schemas.storage.TenantFileSystemStorage'", + id="tenant_schemas.W003" )) return errors From 8cb4d174a670e8ce71c3762e10598418bb808b37 Mon Sep 17 00:00:00 2001 From: viatrak Date: Fri, 24 Mar 2017 12:19:52 -0500 Subject: [PATCH 117/157] Update tests to reflect new IDs in Errors and Warnings --- tenant_schemas/apps.py | 2 +- tenant_schemas/tests/test_apps.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index b7f254b..6b16b62 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -77,7 +77,7 @@ def best_practice(app_configs, **kwargs): if not settings.SHARED_APPS: errors.append( Warning("SHARED_APPS is empty.", - id="tenant_schemas.W002")) + id="tenant_schemas.W002")) if not set(settings.TENANT_APPS).issubset(INSTALLED_APPS): delta = set(settings.TENANT_APPS).difference(INSTALLED_APPS) diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py index a14abd7..e0df917 100644 --- a/tenant_schemas/tests/test_apps.py +++ b/tenant_schemas/tests/test_apps.py @@ -63,7 +63,8 @@ class AppConfigTests(TestCase): obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " - "implementations."), + "implementations.", + id="tenant_schemas.W001"), ]) @override_settings(INSTALLED_APPS=[ @@ -83,7 +84,8 @@ class AppConfigTests(TestCase): def test_tenant_apps_empty(self): self.assertBestPractice([ Error("TENANT_APPS is empty.", - hint="Maybe you don't need this app?"), + hint="Maybe you don't need this app?", + id="tenant_schemas.E001"), ]) @override_settings(PG_EXTRA_SEARCH_PATHS=['public', 'demo1', 'demo2']) @@ -101,7 +103,8 @@ class AppConfigTests(TestCase): @override_settings(SHARED_APPS=()) def test_shared_apps_empty(self): self.assertBestPractice([ - Warning("SHARED_APPS is empty."), + Warning("SHARED_APPS is empty.", + id="tenant_schemas.W002"), ]) @override_settings(TENANT_APPS=( @@ -111,7 +114,8 @@ class AppConfigTests(TestCase): def test_tenant_app_missing_from_install_apps(self): self.assertBestPractice([ Error("You have TENANT_APPS that are not in INSTALLED_APPS", - hint=['django.contrib.flatpages']), + hint=['django.contrib.flatpages'], + id="tenant_schemas.E002"), ]) @override_settings(SHARED_APPS=( @@ -127,5 +131,6 @@ class AppConfigTests(TestCase): def test_shared_app_missing_from_install_apps(self): self.assertBestPractice([ Error("You have SHARED_APPS that are not in INSTALLED_APPS", - hint=['django.contrib.flatpages']), + hint=['django.contrib.flatpages'], + id="tenant_schemas.E003"), ]) From 1cb00dc6db5466b07d9154ba4569cdd18a78b277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Wed, 12 Apr 2017 07:06:19 +0200 Subject: [PATCH 118/157] Django 1.11 support (#463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .travis.yml | 2 +- dts_test_project/dts_test_project/settings.py | 8 +-- tenant_schemas/postgresql_backend/base.py | 51 ++++++++++--------- .../postgresql_backend/introspection.py | 6 ++- tenant_schemas/test/cases.py | 19 ++++++- tenant_schemas/tests/testcases.py | 9 ++++ tox.ini | 7 +-- 7 files changed, 69 insertions(+), 33 deletions(-) diff --git a/.travis.yml b/.travis.yml index d83ff55..3769b5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,8 +14,8 @@ script: - tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} env: - DJANGO=1.8 -- DJANGO=1.9 - DJANGO=1.10 +- DJANGO=1.11 deploy: provider: pypi user: bcarneiro diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 58716a7..245b017 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -82,10 +82,10 @@ DATABASES = { 'default': { 'ENGINE': 'tenant_schemas.postgresql_backend', 'NAME': os.environ.get('PG_NAME', 'dts_test_project'), - 'USER': os.environ.get('PG_USER', 'postgres'), - 'PASSWORD': os.environ.get('PG_PASSWORD', 'root'), - 'HOST': os.environ.get('PG_HOST', 'localhost'), - 'PORT': int(os.environ.get('PG_PORT', '5432')), + 'USER': os.environ.get('PG_USER'), + 'PASSWORD': os.environ.get('PG_PASSWORD'), + 'HOST': os.environ.get('PG_HOST'), + 'PORT': int(os.environ.get('PG_PORT')) if os.environ.get('PG_PORT') else None, } } diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index fb3cf1f..08555aa 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -1,21 +1,17 @@ import re 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 -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', []) @@ -109,12 +105,16 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): category=DeprecationWarning) return self.tenant - def _cursor(self): + def _cursor(self, name=None): """ Here it happens. We hope every Django db operation using PostgreSQL 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 # 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.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 # 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 # we do not have to worry that it's not the good one 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): self.search_path_set = False else: self.search_path_set = True + + if name: + cursor_for_search_path.close() + return cursor @@ -157,10 +169,3 @@ class FakeTenant: """ def __init__(self, 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 diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 4128e1e..ca445d4 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -7,7 +7,11 @@ from django.db.backends.base.introspection import ( ) 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): diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index beb489c..cd05c6b 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -1,15 +1,30 @@ from django.core.management import call_command +from django.conf import settings from django.db import connection from django.test import TestCase from tenant_schemas.utils import get_public_schema_name from tenant_schemas.utils import get_tenant_model +ALLOWED_TEST_DOMAIN = '.test.com' + 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 def setUpClass(cls): cls.sync_shared() + cls.add_allowed_test_domain() tenant_domain = 'tenant.test.com' 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? @@ -21,6 +36,7 @@ class TenantTestCase(TestCase): connection.set_schema_to_public() cls.tenant.delete() + cls.remove_allowed_test_domain() cursor = connection.cursor() cursor.execute('DROP SCHEMA IF EXISTS test CASCADE') @@ -33,10 +49,10 @@ class TenantTestCase(TestCase): class FastTenantTestCase(TenantTestCase): - @classmethod def setUpClass(cls): cls.sync_shared() + cls.add_allowed_test_domain() tenant_domain = 'tenant.test.com' TenantModel = get_tenant_model() @@ -51,3 +67,4 @@ class FastTenantTestCase(TenantTestCase): @classmethod def tearDownClass(cls): connection.set_schema_to_public() + cls.remove_allowed_test_domain() diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 18531da..558eafa 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -20,6 +20,8 @@ class BaseTestCase(TestCase): 'django.contrib.contenttypes', 'django.contrib.auth', ) 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 # a blank public schema for this set of tests. @@ -29,6 +31,13 @@ class BaseTestCase(TestCase): % (get_public_schema_name(), get_public_schema_name())) 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): connection.set_schema_to_public() super(BaseTestCase, self).setUp() diff --git a/tox.ini b/tox.ini index fc4f8c5..6071ab3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,22 @@ [tox] -envlist = py{27,35}-dj{18,19,110} +envlist = py{27,35}-dj{18,19,110,111} [testenv] usedevelop = True -deps = +deps = coverage mock tblib dj18: Django~=1.8.0 dj19: Django~=1.9.0 dj110: Django~=1.10.0 + dj111: Django~=1.11rc1 changedir = dts_test_project passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT -commands = +commands = coverage run manage.py test --noinput {posargs:tenant_schemas} coverage report -m --include=../tenant_schemas/* From 8faa5a74bb6fff2ca0aed2d289a62c4441b52cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Wed, 12 Apr 2017 07:08:55 +0200 Subject: [PATCH 119/157] Fix documentation about custom management commands (#464) --- docs/test.rst | 7 ++++--- docs/use.rst | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/test.rst b/docs/test.rst index 459b0c4..65968c6 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -1,6 +1,7 @@ -================== +===== Tests -================== +===== + Running the tests ----------------- Run these tests from the project ``dts_test_project``, it comes prepacked with the correct settings file and extra apps to enable tests to ensure different apps can exist in ``SHARED_APPS`` and ``TENANT_APPS``. @@ -27,7 +28,7 @@ Because django will not create tenants for you during your tests, we have packed class BaseSetup(TenantTestCase): def setUp(self): self.c = TenantClient(self.tenant) - + def test_user_profile_view(self): response = self.c.get(reverse('user_profile')) self.assertEqual(response.status_code, 200) diff --git a/docs/use.rst b/docs/use.rst index da4cf13..4373d09 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -1,6 +1,7 @@ =========================== Using django-tenant-schemas =========================== + Supported versions ------------------ You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. @@ -9,7 +10,7 @@ It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ens Creating a Tenant ----------------- -Creating a tenant works just like any other model in django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. +Creating a tenant works just like any other model in Django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. .. code-block:: python @@ -43,7 +44,7 @@ Any call to the methods ``filter``, ``get``, ``save``, ``delete`` or any other f Management commands ------------------- -Every command except tenant_command runs by default on all tenants. You can also create your own commands that run on every tenant by inheriting ``BaseTenantCommand``. +By default, base commands run on the public tenant but you can also own commands that run on a specific tenant by inheriting ``BaseTenantCommand``. For example, if you have the following ``do_foo`` command in the ``foo`` app: @@ -57,7 +58,7 @@ For example, if you have the following ``do_foo`` command in the ``foo`` app: def handle(self, *args, **options): do_foo() -You could create a wrapper command ``tenant_do_foo`` by using ``BaseTenantCommand`` like so: +You could create a wrapper command by using ``BaseTenantCommand``: ``foo/management/commands/tenant_do_foo.py`` @@ -68,11 +69,13 @@ You could create a wrapper command ``tenant_do_foo`` by using ``BaseTenantComman class Command(BaseTenantCommand): COMMAND_NAME = 'do_foo' -To run only a particular schema, there is an optional argument called ``--schema``. +To run the command on a particular schema, there is an optional argument called ``--schema``. .. code-block:: bash - ./manage.py migrate_schemas --schema=customer1 + ./manage.py tenant_command do_foo --schema=customer1 + +If you omit the ``schema`` argument, the interactive shell will ask you to select one. migrate_schemas ~~~~~~~~~~~~~~~ From 42ef0234367b54bbe3a73397e9b609b668d25360 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sun, 16 Apr 2017 22:35:33 +1000 Subject: [PATCH 120/157] Updates for Django 1.11 --- docs/conf.py | 4 ++-- docs/use.rst | 4 ++-- tox.ini | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3b032f3..971871a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -94,8 +94,8 @@ pygments_style = 'sphinx' intersphinx_mapping = { 'django': ( - 'https://docs.djangoproject.com/en/1.10/', - 'https://docs.djangoproject.com/en/1.10/_objects/'), + 'https://docs.djangoproject.com/en/1.11/', + 'https://docs.djangoproject.com/en/1.11/_objects/'), } # -- Options for HTML output --------------------------------------------------- diff --git a/docs/use.rst b/docs/use.rst index 4373d09..4085fba 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -4,9 +4,9 @@ Using django-tenant-schemas Supported versions ------------------ -You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. +You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. -It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 9.2, the minimum for Django 1.10. +It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 9.3, the minimum for Django 1.11. Creating a Tenant ----------------- diff --git a/tox.ini b/tox.ini index 6071ab3..ffea785 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,35}-dj{18,19,110,111} +envlist = py{27,35}-dj{18,110,111} [testenv] usedevelop = True From 71019cded91ca0ab00627ae067001cc4c07564a0 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sun, 16 Apr 2017 22:57:06 +1000 Subject: [PATCH 121/157] =?UTF-8?q?Bump=20version:=201.7.0=20=E2=86=92=201?= =?UTF-8?q?.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 72e6b8f..fb542ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.7.0 +current_version = 1.8.0 commit = True tag = True From 36e1ba3800de51486e03495c6708748b6c0fe13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Wed, 12 Apr 2017 07:06:19 +0200 Subject: [PATCH 122/157] Django 1.11 support (#463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .travis.yml | 2 +- dts_test_project/dts_test_project/settings.py | 8 +-- tenant_schemas/postgresql_backend/base.py | 51 ++++++++++--------- .../postgresql_backend/introspection.py | 6 ++- tenant_schemas/test/cases.py | 19 ++++++- tenant_schemas/tests/testcases.py | 9 ++++ tox.ini | 7 +-- 7 files changed, 69 insertions(+), 33 deletions(-) diff --git a/.travis.yml b/.travis.yml index d83ff55..3769b5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,8 +14,8 @@ script: - tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} env: - DJANGO=1.8 -- DJANGO=1.9 - DJANGO=1.10 +- DJANGO=1.11 deploy: provider: pypi user: bcarneiro diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 58716a7..245b017 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -82,10 +82,10 @@ DATABASES = { 'default': { 'ENGINE': 'tenant_schemas.postgresql_backend', 'NAME': os.environ.get('PG_NAME', 'dts_test_project'), - 'USER': os.environ.get('PG_USER', 'postgres'), - 'PASSWORD': os.environ.get('PG_PASSWORD', 'root'), - 'HOST': os.environ.get('PG_HOST', 'localhost'), - 'PORT': int(os.environ.get('PG_PORT', '5432')), + 'USER': os.environ.get('PG_USER'), + 'PASSWORD': os.environ.get('PG_PASSWORD'), + 'HOST': os.environ.get('PG_HOST'), + 'PORT': int(os.environ.get('PG_PORT')) if os.environ.get('PG_PORT') else None, } } diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index fb3cf1f..08555aa 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -1,21 +1,17 @@ import re 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 -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', []) @@ -109,12 +105,16 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): category=DeprecationWarning) return self.tenant - def _cursor(self): + def _cursor(self, name=None): """ Here it happens. We hope every Django db operation using PostgreSQL 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 # 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.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 # 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 # we do not have to worry that it's not the good one 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): self.search_path_set = False else: self.search_path_set = True + + if name: + cursor_for_search_path.close() + return cursor @@ -157,10 +169,3 @@ class FakeTenant: """ def __init__(self, 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 diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 4128e1e..ca445d4 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -7,7 +7,11 @@ from django.db.backends.base.introspection import ( ) 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): diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index beb489c..cd05c6b 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -1,15 +1,30 @@ from django.core.management import call_command +from django.conf import settings from django.db import connection from django.test import TestCase from tenant_schemas.utils import get_public_schema_name from tenant_schemas.utils import get_tenant_model +ALLOWED_TEST_DOMAIN = '.test.com' + 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 def setUpClass(cls): cls.sync_shared() + cls.add_allowed_test_domain() tenant_domain = 'tenant.test.com' 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? @@ -21,6 +36,7 @@ class TenantTestCase(TestCase): connection.set_schema_to_public() cls.tenant.delete() + cls.remove_allowed_test_domain() cursor = connection.cursor() cursor.execute('DROP SCHEMA IF EXISTS test CASCADE') @@ -33,10 +49,10 @@ class TenantTestCase(TestCase): class FastTenantTestCase(TenantTestCase): - @classmethod def setUpClass(cls): cls.sync_shared() + cls.add_allowed_test_domain() tenant_domain = 'tenant.test.com' TenantModel = get_tenant_model() @@ -51,3 +67,4 @@ class FastTenantTestCase(TenantTestCase): @classmethod def tearDownClass(cls): connection.set_schema_to_public() + cls.remove_allowed_test_domain() diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 18531da..558eafa 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -20,6 +20,8 @@ class BaseTestCase(TestCase): 'django.contrib.contenttypes', 'django.contrib.auth', ) 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 # a blank public schema for this set of tests. @@ -29,6 +31,13 @@ class BaseTestCase(TestCase): % (get_public_schema_name(), get_public_schema_name())) 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): connection.set_schema_to_public() super(BaseTestCase, self).setUp() diff --git a/tox.ini b/tox.ini index fc4f8c5..6071ab3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,22 @@ [tox] -envlist = py{27,35}-dj{18,19,110} +envlist = py{27,35}-dj{18,19,110,111} [testenv] usedevelop = True -deps = +deps = coverage mock tblib dj18: Django~=1.8.0 dj19: Django~=1.9.0 dj110: Django~=1.10.0 + dj111: Django~=1.11rc1 changedir = dts_test_project passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT -commands = +commands = coverage run manage.py test --noinput {posargs:tenant_schemas} coverage report -m --include=../tenant_schemas/* From 6f6c871a0c4b797e655fe4bea29f802ca3b3e798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Wed, 12 Apr 2017 07:08:55 +0200 Subject: [PATCH 123/157] Fix documentation about custom management commands (#464) --- docs/test.rst | 7 ++++--- docs/use.rst | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/test.rst b/docs/test.rst index 459b0c4..65968c6 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -1,6 +1,7 @@ -================== +===== Tests -================== +===== + Running the tests ----------------- Run these tests from the project ``dts_test_project``, it comes prepacked with the correct settings file and extra apps to enable tests to ensure different apps can exist in ``SHARED_APPS`` and ``TENANT_APPS``. @@ -27,7 +28,7 @@ Because django will not create tenants for you during your tests, we have packed class BaseSetup(TenantTestCase): def setUp(self): self.c = TenantClient(self.tenant) - + def test_user_profile_view(self): response = self.c.get(reverse('user_profile')) self.assertEqual(response.status_code, 200) diff --git a/docs/use.rst b/docs/use.rst index da4cf13..4373d09 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -1,6 +1,7 @@ =========================== Using django-tenant-schemas =========================== + Supported versions ------------------ You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. @@ -9,7 +10,7 @@ It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ens Creating a Tenant ----------------- -Creating a tenant works just like any other model in django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. +Creating a tenant works just like any other model in Django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. .. code-block:: python @@ -43,7 +44,7 @@ Any call to the methods ``filter``, ``get``, ``save``, ``delete`` or any other f Management commands ------------------- -Every command except tenant_command runs by default on all tenants. You can also create your own commands that run on every tenant by inheriting ``BaseTenantCommand``. +By default, base commands run on the public tenant but you can also own commands that run on a specific tenant by inheriting ``BaseTenantCommand``. For example, if you have the following ``do_foo`` command in the ``foo`` app: @@ -57,7 +58,7 @@ For example, if you have the following ``do_foo`` command in the ``foo`` app: def handle(self, *args, **options): do_foo() -You could create a wrapper command ``tenant_do_foo`` by using ``BaseTenantCommand`` like so: +You could create a wrapper command by using ``BaseTenantCommand``: ``foo/management/commands/tenant_do_foo.py`` @@ -68,11 +69,13 @@ You could create a wrapper command ``tenant_do_foo`` by using ``BaseTenantComman class Command(BaseTenantCommand): COMMAND_NAME = 'do_foo' -To run only a particular schema, there is an optional argument called ``--schema``. +To run the command on a particular schema, there is an optional argument called ``--schema``. .. code-block:: bash - ./manage.py migrate_schemas --schema=customer1 + ./manage.py tenant_command do_foo --schema=customer1 + +If you omit the ``schema`` argument, the interactive shell will ask you to select one. migrate_schemas ~~~~~~~~~~~~~~~ From 6b8d41469ac8d376291101caddff9e161a0da693 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sun, 16 Apr 2017 22:35:33 +1000 Subject: [PATCH 124/157] Updates for Django 1.11 --- docs/conf.py | 4 ++-- docs/use.rst | 4 ++-- tox.ini | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3b032f3..971871a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -94,8 +94,8 @@ pygments_style = 'sphinx' intersphinx_mapping = { 'django': ( - 'https://docs.djangoproject.com/en/1.10/', - 'https://docs.djangoproject.com/en/1.10/_objects/'), + 'https://docs.djangoproject.com/en/1.11/', + 'https://docs.djangoproject.com/en/1.11/_objects/'), } # -- Options for HTML output --------------------------------------------------- diff --git a/docs/use.rst b/docs/use.rst index 4373d09..4085fba 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -4,9 +4,9 @@ Using django-tenant-schemas Supported versions ------------------ -You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. +You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. -It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 9.2, the minimum for Django 1.10. +It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 9.3, the minimum for Django 1.11. Creating a Tenant ----------------- diff --git a/tox.ini b/tox.ini index 6071ab3..ffea785 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,35}-dj{18,19,110,111} +envlist = py{27,35}-dj{18,110,111} [testenv] usedevelop = True From 090f5b3ef7bb5680461d143327133cc2deb88fdd Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sun, 16 Apr 2017 22:57:06 +1000 Subject: [PATCH 125/157] =?UTF-8?q?Bump=20version:=201.7.0=20=E2=86=92=201?= =?UTF-8?q?.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 72e6b8f..fb542ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.7.0 +current_version = 1.8.0 commit = True tag = True From 8634634b1c34215f99d05eaf0921f19d0bb6fbf0 Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Mon, 17 Apr 2017 15:52:37 +0500 Subject: [PATCH 126/157] Django 1.9 Support --- tenant_schemas/template_loaders.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 24c36da..4889edf 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -4,28 +4,28 @@ multi-tenant setting """ import hashlib + from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import connection +from django.template import TemplateDoesNotExist from django.template.base import Template from django.template.loaders.base import Loader as BaseLoader from django.utils._os import safe_join from django.utils.encoding import force_bytes + from tenant_schemas.postgresql_backend.base import FakeTenant -DJANGO_1_10 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 10 +DJANGO_1_9 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 9 -if DJANGO_1_10: - from django.template import Origin, TemplateDoesNotExist +if DJANGO_1_9: + from django.template import Origin def make_origin(engine, name, loader, template_name, dirs): return Origin(name=name, template_name=template_name, loader=loader) else: - from django.template.base import TemplateDoesNotExist - - def make_origin(engine, name, loader, template_name, dirs): return engine.make_origin(name, loader, template_name, dirs) From a21ed181fee5b999a2b03ce028440bf568d47fb8 Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Mon, 17 Apr 2017 15:53:56 +0500 Subject: [PATCH 127/157] Unit Tests for CachedLoader --- tenant_schemas/tests/__init__.py | 5 +-- .../tests/template_loader/__init__.py | 1 + .../template_loader/templates/hello.html | 1 + .../test_cached_template_loader.py | 31 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tenant_schemas/tests/template_loader/__init__.py create mode 100755 tenant_schemas/tests/template_loader/templates/hello.html create mode 100755 tenant_schemas/tests/template_loader/test_cached_template_loader.py diff --git a/tenant_schemas/tests/__init__.py b/tenant_schemas/tests/__init__.py index 09a3f81..2b6c0f2 100644 --- a/tenant_schemas/tests/__init__.py +++ b/tenant_schemas/tests/__init__.py @@ -1,5 +1,6 @@ -from .test_routes import * -from .test_tenants import * +from .template_loader import * from .test_cache import * from .test_log import * +from .test_routes import * +from .test_tenants import * from .test_utils import * diff --git a/tenant_schemas/tests/template_loader/__init__.py b/tenant_schemas/tests/template_loader/__init__.py new file mode 100644 index 0000000..ac17d9d --- /dev/null +++ b/tenant_schemas/tests/template_loader/__init__.py @@ -0,0 +1 @@ +from .test_cached_template_loader import CachedLoaderTests diff --git a/tenant_schemas/tests/template_loader/templates/hello.html b/tenant_schemas/tests/template_loader/templates/hello.html new file mode 100755 index 0000000..7f54a62 --- /dev/null +++ b/tenant_schemas/tests/template_loader/templates/hello.html @@ -0,0 +1 @@ +Hello! (Django templates) diff --git a/tenant_schemas/tests/template_loader/test_cached_template_loader.py b/tenant_schemas/tests/template_loader/test_cached_template_loader.py new file mode 100755 index 0000000..39facfe --- /dev/null +++ b/tenant_schemas/tests/template_loader/test_cached_template_loader.py @@ -0,0 +1,31 @@ +import os + +from django.template.loader import get_template +from django.test import SimpleTestCase, override_settings + + +@override_settings( + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.dirname(__file__), "templates") + ], + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + ], + 'loaders': [ + ('tenant_schemas.template_loaders.CachedLoader', ( + 'tenant_schemas.template_loaders.FilesystemLoader', + 'django.template.loaders.filesystem.Loader' + )) + ] + }, + } + ] +) +class CachedLoaderTests(SimpleTestCase): + def test_get_template(self): + template = get_template("hello.html") + self.assertEqual(template.render(), "Hello! (Django templates)\n") From ab129048269511504ebe5491810ba5bae6d4601c Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Mon, 17 Apr 2017 15:54:16 +0500 Subject: [PATCH 128/157] Migrated to TEMPLATES settings --- dts_test_project/dts_test_project/settings.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 245b017..9378c67 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -10,8 +10,8 @@ https://docs.djangoproject.com/en/1.8/ref/settings/ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ @@ -28,7 +28,6 @@ ALLOWED_HOSTS = [] DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' - # Application definition SHARED_APPS = ( @@ -74,7 +73,6 @@ ROOT_URLCONF = 'dts_test_project.urls' WSGI_APPLICATION = 'dts_test_project.wsgi.application' - # Database # https://docs.djangoproject.com/en/1.8/ref/settings/#databases @@ -111,6 +109,16 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.contrib.messages.context_processors.messages', ) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'context_processors': TEMPLATE_CONTEXT_PROCESSORS + }, + } +] + # Internationalization # https://docs.djangoproject.com/en/1.8/topics/i18n/ @@ -124,7 +132,6 @@ USE_L10N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.8/howto/static-files/ From 400fdc26f3aa612129c5c618e2fa65acfb39841c Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Fri, 5 May 2017 16:15:10 -0500 Subject: [PATCH 129/157] Create install.rst Remove outdated setting TENANT_CREATION_FAKES_MIGRATIONS from the docs. Fixes #458. --- docs/install.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 4b64894..e0c2124 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -175,12 +175,6 @@ Optional Settings The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. -.. attribute:: TENANT_CREATION_FAKES_MIGRATIONS - - :Default: ``'True'`` - - Sets if the models will be synced directly to the last version and all migration subsequently faked. Useful in the cases where migrations can not be faked and need to be ran individually. Be aware that setting this to `False` may significantly slow down the process of creating tenants. - Tenant View-Routing ------------------- From 4a49516c4ab56639f7ede34e2b2d8967f7d2fd2c Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Sat, 6 May 2017 07:21:48 -0500 Subject: [PATCH 130/157] Updated docs regarding tests --- docs/test.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/test.rst b/docs/test.rst index 65968c6..2f5d8b8 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -36,9 +36,7 @@ Because django will not create tenants for you during your tests, we have packed Running tests faster -------------------- -Using the ``TenantTestCase`` can make running your tests really slow quite early in your project. This is due to the fact that it drops, recreates the test schema and runs migrations for every ``TenantTestCase`` you have. If you want to gain speed, there's a ``FastTenantTestCase`` where the test schema will be created and migrations ran only one time. The gain in speed is noticiable but be aware that by using this you will be perpertraiting state between your test cases, please make sure your they wont be affected by this. - -Running tests using ``TenantTestCase`` can start being a bottleneck once the number of tests grow. If you do not care that the state between tests is kept, an alternative is to use the class ``FastTenantTestCase``. Unlike ``TenantTestCase``, the test schema and its migrations will only be created and ran once. This is a significant improvement in speed coming at the cost of shared state. +Running tests using ``TenantTestCase`` can start being a bottleneck once the number of tests grow. ``TenantTestCase`` drops, recreates and executes migrations for the test schema every time for every ``TenantTestCase`` you have. If you do not care that the state between tests is kept, an alternative is to use the class ``FastTenantTestCase``. Unlike ``TenantTestCase``, the test schema and its migrations will only be created and ran once. This is a significant improvement in speed coming at the cost of shared state. .. code-block:: python From 94667818226ef5709010bece5c40a70ad3facb3d Mon Sep 17 00:00:00 2001 From: Juan Marquez Date: Sun, 7 May 2017 13:00:49 -0400 Subject: [PATCH 131/157] Compatibility with Django 1.11 LTS (Tutorial) #477 -------------------------------------------------- Update tenant_tutorial to Django 1.11 To create a PR --- .../tenant_tutorial/settings.py | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/examples/tenant_tutorial/tenant_tutorial/settings.py b/examples/tenant_tutorial/tenant_tutorial/settings.py index 0a3d190..bb78a91 100644 --- a/examples/tenant_tutorial/tenant_tutorial/settings.py +++ b/examples/tenant_tutorial/tenant_tutorial/settings.py @@ -1,9 +1,9 @@ import os +from collections import OrderedDict # Django settings for tenant_tutorial project. DEBUG = True -TEMPLATE_DEBUG = DEBUG ADMINS = ( # ('Your Name', 'your_email@example.com'), @@ -24,7 +24,7 @@ DATABASES = { # Hosts/domain names that are valid for this site; required if DEBUG is False # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['localhost', '.trendy-sass.com'] # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name @@ -85,11 +85,6 @@ STATICFILES_FINDERS = ( # Make this unique, and don't share it with anybody. SECRET_KEY = 'as-%*_93v=r5*p_7cu8-%o6b&x^g+q$#*e*fl)k)x0-t=%q0qa' -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -) DATABASE_ROUTERS = ( 'tenant_schemas.routers.TenantSyncRouter', @@ -108,22 +103,37 @@ MIDDLEWARE_CLASSES = ( # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) -TEMPLATE_CONTEXT_PROCESSORS = ( - 'django.core.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.core.context_processors.debug', - 'django.core.context_processors.media', - 'django.core.context_processors.static', - 'django.contrib.messages.context_processors.messages', -) - ROOT_URLCONF = 'tenant_tutorial.urls_tenants' PUBLIC_SCHEMA_URLCONF = 'tenant_tutorial.urls_public' # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'tenant_tutorial.wsgi.application' -TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), '..', 'templates').replace('\\', '/'),) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.dirname(__file__), '..', 'templates').replace('\\', '/'), + ], + 'APP_DIRS': False, + 'OPTIONS': { + 'debug': DEBUG, + 'context_processors': [ + # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this + #~ 'django.core.context_processors.request', + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + # List of callables that know how to import templates from various sources. + 'loaders': [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', # insert your TEMPLATE_LOADERS here + ] + }, + }, +] SHARED_APPS = ( 'tenant_schemas', # mandatory @@ -144,7 +154,9 @@ TENANT_APPS = ( TENANT_MODEL = "customers.Client" # app.Model -INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS)) +DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' + +INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS)) SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' From 4131b5f3ff701615da445459a754981fbfa14288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Tue, 21 Mar 2017 15:16:56 +0100 Subject: [PATCH 132/157] Don't run makemigrations until set up is done (#459) --- docs/install.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 4eb0b64..5980612 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -56,8 +56,6 @@ If you'd like to serve the public tenant for unrecognised hostnames instead, use #... ) -Make sure you have ``django.template.context_processors.request`` listed under ``context_processors`` (Django 1.8+): - .. code-block:: python TEMPLATES = [ @@ -102,11 +100,7 @@ Now we have to create your tenant model. Your tenant model can contain whichever # default true, schema will be automatically created and synced when it is saved auto_create_schema = True -Once you have defined your model, don't forget to create the migrations for it or otherwise Django >= 1.9 will not create its table. Replace ``customers`` with your app name. - -.. code-block:: bash - - python manage.py makemigrations customers +Before creating the migrations, we must configure a few specific settings. Configure Tenant and Shared Applications ======================================== @@ -156,7 +150,13 @@ You also have to set where your tenant model is. TENANT_MODEL = "customers.Client" # app.Model -Now run ``migrate_schemas --shared`` to create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command. +Now you must create your app migrations for ``customers``: + +.. code-block:: bash + + python manage.py makemigrations customers + +The command ``migrate_schemas --shared`` will create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command. .. code-block:: bash From c4e34cdfdadfb0b7dd6896b9c23703062d8ac101 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 2 Jun 2017 06:56:57 +1000 Subject: [PATCH 133/157] Remove Django 1.9 from testing matrix, unsupported version. --- .travis.yml | 1 - tox.ini | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 845cc71..d83cf90 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,6 @@ addons: install: pip install -q tox-travis env: - DJANGO=1.8 - - DJANGO=1.9 - DJANGO=1.10 - DJANGO=1.11 matrix: diff --git a/tox.ini b/tox.ini index 839fe27..29ee264 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py{27,35}-dj{18,19,110,111}-{standard,parallel} +envlist = py{27,35}-dj{18,110,111}-{standard,parallel} [travis:env] DJANGO = 1.8: dj18-{standard,parallel} - 1.9: dj19-{standard,parallel} 1.10: dj110-{standard,parallel} 1.11: dj111-{standard,parallel} @@ -16,9 +15,8 @@ deps = mock tblib dj18: Django~=1.8.0 - dj19: Django~=1.9.0 dj110: Django~=1.10.0 - dj111: Django~=1.11rc1 + dj111: Django~=1.11.0 changedir = dts_test_project From bd99102e2e185d319bc7d5d12a208f3cf947a23f Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 2 Jun 2017 17:56:37 +1000 Subject: [PATCH 134/157] Deal with backwards compatibility by exception. --- tenant_schemas/template_loaders.py | 9 +++------ tenant_schemas/test/cases.py | 4 ---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 4889edf..6224a26 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -5,7 +5,6 @@ multi-tenant setting import hashlib -from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import connection @@ -17,15 +16,13 @@ from django.utils.encoding import force_bytes from tenant_schemas.postgresql_backend.base import FakeTenant -DJANGO_1_9 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 9 - -if DJANGO_1_9: +try: from django.template import Origin - def make_origin(engine, name, loader, template_name, dirs): return Origin(name=name, template_name=template_name, loader=loader) -else: + +except ImportError: # Django 1.8 backwards compatibility def make_origin(engine, name, loader, template_name, dirs): return engine.make_origin(name, loader, template_name, dirs) diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index 4fe8b36..beef8dd 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -1,15 +1,11 @@ from django.conf import settings from django.core.management import call_command -from django.conf import settings from django.db import connection from django.test import TestCase - from tenant_schemas.utils import get_public_schema_name, get_tenant_model ALLOWED_TEST_DOMAIN = '.test.com' -ALLOWED_TEST_DOMAIN = '.test.com' - class TenantTestCase(TestCase): @classmethod From f258521ebe55f61a97bfb69e97788b0f451618c1 Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Thu, 16 Mar 2017 00:47:17 +0500 Subject: [PATCH 135/157] Django 1.10 Support --- tenant_schemas/template_loaders.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index c8c98ee..7cf1342 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -4,16 +4,31 @@ multi-tenant setting """ import hashlib +from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.template.base import TemplateDoesNotExist, Template -from django.utils.encoding import force_bytes -from django.utils._os import safe_join from django.db import connection +from django.template.base import Template from django.template.loaders.base import Loader as BaseLoader - +from django.utils._os import safe_join +from django.utils.encoding import force_bytes from tenant_schemas.postgresql_backend.base import FakeTenant +DJANGO_1_10 = DJANGO_VERSION[1] >= 10 + +if DJANGO_1_10: + from django.template import Origin, TemplateDoesNotExist + + + def make_origin(engine, name, loader, template_name, dirs): + return Origin(name=name, template_name=template_name, loader=loader) +else: + from django.template.base import TemplateDoesNotExist + + + def make_origin(engine, name, loader, template_name, dirs): + return engine.make_origin(name, loader, template_name, dirs) + class CachedLoader(BaseLoader): is_usable = True @@ -50,7 +65,7 @@ class CachedLoader(BaseLoader): except TemplateDoesNotExist: pass else: - origin = self.engine.make_origin(display_name, loader, name, dirs) + origin = make_origin(self.engine, display_name, loader, name, dirs) result = template, origin break self.find_template_cache[key] = result @@ -133,4 +148,5 @@ class FilesystemLoader(BaseLoader): else: error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." raise TemplateDoesNotExist(error_msg) + load_template_source.is_usable = True From 1fcb4917336bb284035107edd31d3d39d49c411e Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Thu, 16 Mar 2017 00:52:07 +0500 Subject: [PATCH 136/157] Updated Django 1.10 Version Check --- tenant_schemas/template_loaders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 7cf1342..24c36da 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -14,7 +14,7 @@ from django.utils._os import safe_join from django.utils.encoding import force_bytes from tenant_schemas.postgresql_backend.base import FakeTenant -DJANGO_1_10 = DJANGO_VERSION[1] >= 10 +DJANGO_1_10 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 10 if DJANGO_1_10: from django.template import Origin, TemplateDoesNotExist From 472456d4d227dc052e81197714ebbe854a9e6c9b Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Mon, 17 Apr 2017 15:52:37 +0500 Subject: [PATCH 137/157] Django 1.9 Support --- tenant_schemas/template_loaders.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 24c36da..4889edf 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -4,28 +4,28 @@ multi-tenant setting """ import hashlib + from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import connection +from django.template import TemplateDoesNotExist from django.template.base import Template from django.template.loaders.base import Loader as BaseLoader from django.utils._os import safe_join from django.utils.encoding import force_bytes + from tenant_schemas.postgresql_backend.base import FakeTenant -DJANGO_1_10 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 10 +DJANGO_1_9 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 9 -if DJANGO_1_10: - from django.template import Origin, TemplateDoesNotExist +if DJANGO_1_9: + from django.template import Origin def make_origin(engine, name, loader, template_name, dirs): return Origin(name=name, template_name=template_name, loader=loader) else: - from django.template.base import TemplateDoesNotExist - - def make_origin(engine, name, loader, template_name, dirs): return engine.make_origin(name, loader, template_name, dirs) From c1842304c62fd5806c009cfe703acc35c5289444 Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Mon, 17 Apr 2017 15:53:56 +0500 Subject: [PATCH 138/157] Unit Tests for CachedLoader --- tenant_schemas/tests/__init__.py | 5 +-- .../tests/template_loader/__init__.py | 1 + .../template_loader/templates/hello.html | 1 + .../test_cached_template_loader.py | 31 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tenant_schemas/tests/template_loader/__init__.py create mode 100755 tenant_schemas/tests/template_loader/templates/hello.html create mode 100755 tenant_schemas/tests/template_loader/test_cached_template_loader.py diff --git a/tenant_schemas/tests/__init__.py b/tenant_schemas/tests/__init__.py index 09a3f81..2b6c0f2 100644 --- a/tenant_schemas/tests/__init__.py +++ b/tenant_schemas/tests/__init__.py @@ -1,5 +1,6 @@ -from .test_routes import * -from .test_tenants import * +from .template_loader import * from .test_cache import * from .test_log import * +from .test_routes import * +from .test_tenants import * from .test_utils import * diff --git a/tenant_schemas/tests/template_loader/__init__.py b/tenant_schemas/tests/template_loader/__init__.py new file mode 100644 index 0000000..ac17d9d --- /dev/null +++ b/tenant_schemas/tests/template_loader/__init__.py @@ -0,0 +1 @@ +from .test_cached_template_loader import CachedLoaderTests diff --git a/tenant_schemas/tests/template_loader/templates/hello.html b/tenant_schemas/tests/template_loader/templates/hello.html new file mode 100755 index 0000000..7f54a62 --- /dev/null +++ b/tenant_schemas/tests/template_loader/templates/hello.html @@ -0,0 +1 @@ +Hello! (Django templates) diff --git a/tenant_schemas/tests/template_loader/test_cached_template_loader.py b/tenant_schemas/tests/template_loader/test_cached_template_loader.py new file mode 100755 index 0000000..39facfe --- /dev/null +++ b/tenant_schemas/tests/template_loader/test_cached_template_loader.py @@ -0,0 +1,31 @@ +import os + +from django.template.loader import get_template +from django.test import SimpleTestCase, override_settings + + +@override_settings( + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.dirname(__file__), "templates") + ], + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + ], + 'loaders': [ + ('tenant_schemas.template_loaders.CachedLoader', ( + 'tenant_schemas.template_loaders.FilesystemLoader', + 'django.template.loaders.filesystem.Loader' + )) + ] + }, + } + ] +) +class CachedLoaderTests(SimpleTestCase): + def test_get_template(self): + template = get_template("hello.html") + self.assertEqual(template.render(), "Hello! (Django templates)\n") From c2aa3a1662848b631272eb5328b67a2676dee881 Mon Sep 17 00:00:00 2001 From: Adnan Umer Date: Mon, 17 Apr 2017 15:54:16 +0500 Subject: [PATCH 139/157] Migrated to TEMPLATES settings --- dts_test_project/dts_test_project/settings.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 245b017..9378c67 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -10,8 +10,8 @@ https://docs.djangoproject.com/en/1.8/ref/settings/ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ @@ -28,7 +28,6 @@ ALLOWED_HOSTS = [] DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' - # Application definition SHARED_APPS = ( @@ -74,7 +73,6 @@ ROOT_URLCONF = 'dts_test_project.urls' WSGI_APPLICATION = 'dts_test_project.wsgi.application' - # Database # https://docs.djangoproject.com/en/1.8/ref/settings/#databases @@ -111,6 +109,16 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.contrib.messages.context_processors.messages', ) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'context_processors': TEMPLATE_CONTEXT_PROCESSORS + }, + } +] + # Internationalization # https://docs.djangoproject.com/en/1.8/topics/i18n/ @@ -124,7 +132,6 @@ USE_L10N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.8/howto/static-files/ From 2abbbdab433bcbb74f2d19ebc47f78122fb07ecb Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 2 Jun 2017 06:56:57 +1000 Subject: [PATCH 140/157] Remove Django 1.9 from testing matrix, unsupported version. --- .travis.yml | 1 - tox.ini | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 845cc71..d83cf90 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,6 @@ addons: install: pip install -q tox-travis env: - DJANGO=1.8 - - DJANGO=1.9 - DJANGO=1.10 - DJANGO=1.11 matrix: diff --git a/tox.ini b/tox.ini index 839fe27..29ee264 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py{27,35}-dj{18,19,110,111}-{standard,parallel} +envlist = py{27,35}-dj{18,110,111}-{standard,parallel} [travis:env] DJANGO = 1.8: dj18-{standard,parallel} - 1.9: dj19-{standard,parallel} 1.10: dj110-{standard,parallel} 1.11: dj111-{standard,parallel} @@ -16,9 +15,8 @@ deps = mock tblib dj18: Django~=1.8.0 - dj19: Django~=1.9.0 dj110: Django~=1.10.0 - dj111: Django~=1.11rc1 + dj111: Django~=1.11.0 changedir = dts_test_project From df359d2bfcf890751ec9f363a26da91360dca5b3 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 2 Jun 2017 17:56:37 +1000 Subject: [PATCH 141/157] Deal with backwards compatibility by exception. --- tenant_schemas/template_loaders.py | 9 +++------ tenant_schemas/test/cases.py | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 4889edf..6224a26 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -5,7 +5,6 @@ multi-tenant setting import hashlib -from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import connection @@ -17,15 +16,13 @@ from django.utils.encoding import force_bytes from tenant_schemas.postgresql_backend.base import FakeTenant -DJANGO_1_9 = DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 9 - -if DJANGO_1_9: +try: from django.template import Origin - def make_origin(engine, name, loader, template_name, dirs): return Origin(name=name, template_name=template_name, loader=loader) -else: + +except ImportError: # Django 1.8 backwards compatibility def make_origin(engine, name, loader, template_name, dirs): return engine.make_origin(name, loader, template_name, dirs) diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index d7a92f8..beef8dd 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -2,7 +2,6 @@ from django.conf import settings from django.core.management import call_command from django.db import connection from django.test import TestCase - from tenant_schemas.utils import get_public_schema_name, get_tenant_model ALLOWED_TEST_DOMAIN = '.test.com' From fe8a09588eabb61a35c284a49a0d6702cfa96360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Raimbault?= Date: Thu, 25 May 2017 16:21:23 +0200 Subject: [PATCH 142/157] Updated tox.ini to final Django 1.11 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 839fe27..05d4d6a 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = dj18: Django~=1.8.0 dj19: Django~=1.9.0 dj110: Django~=1.10.0 - dj111: Django~=1.11rc1 + dj111: Django~=1.11.0 changedir = dts_test_project From f0d3df22909e879fcc66615aa2fa8eb1de0b4672 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 2 Jun 2017 18:30:12 +1000 Subject: [PATCH 143/157] Remove documentation referring to unsupported version. --- docs/install.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 5980612..e7a91f9 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -73,8 +73,6 @@ If you'd like to serve the public tenant for unrecognised hostnames instead, use } ] -If you are using an old version of Django (before v1.8), the following setting must be set in ``TEMPLATE_CONTEXT_PROCESSORS``: - .. code-block:: python TEMPLATE_CONTEXT_PROCESSORS = ( From 510e0f90151b75e70e6c624152fcfa43c1384262 Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Fri, 2 Jun 2017 18:40:03 +1000 Subject: [PATCH 144/157] Explicitly define INSTALLED_APPS in settings.py --- examples/tenant_tutorial/tenant_tutorial/settings.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/tenant_tutorial/tenant_tutorial/settings.py b/examples/tenant_tutorial/tenant_tutorial/settings.py index bb78a91..0f4001c 100644 --- a/examples/tenant_tutorial/tenant_tutorial/settings.py +++ b/examples/tenant_tutorial/tenant_tutorial/settings.py @@ -156,7 +156,15 @@ TENANT_MODEL = "customers.Client" # app.Model DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' -INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS)) +INSTALLED_APPS = ( + 'tenant_schemas', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +) SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' From 5b907e4f05b411a7536f121823f057a6ba34930d Mon Sep 17 00:00:00 2001 From: Gary Reynolds Date: Sat, 3 Jun 2017 21:47:55 +1000 Subject: [PATCH 145/157] =?UTF-8?q?Bump=20version:=201.8.0=20=E2=86=92=201?= =?UTF-8?q?.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index fb542ed..eafaa65 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.8.0 +current_version = 1.9.0 commit = True tag = True From 70fef87576ac997fdb4e1c265152cc8f2929141a Mon Sep 17 00:00:00 2001 From: Bernardo Pires Date: Mon, 5 Jun 2017 21:00:02 +0200 Subject: [PATCH 146/157] Remove mention to MigrateCommand --- tenant_schemas/management/commands/migrate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tenant_schemas/management/commands/migrate.py b/tenant_schemas/management/commands/migrate.py index 663ad33..25ad908 100644 --- a/tenant_schemas/management/commands/migrate.py +++ b/tenant_schemas/management/commands/migrate.py @@ -9,8 +9,7 @@ class Command(BaseCommand): def handle(self, *args, **options): database = options.get('database', 'default') - if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' or - MigrateCommand is BaseCommand): + if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend'): raise CommandError("migrate has been disabled, for database '{0}'. Use migrate_schemas " "instead. Please read the documentation if you don't know why you " "shouldn't call migrate directly!".format(database)) From 953040943dd1ddd8a2203e37c57f4ca257ddde7a Mon Sep 17 00:00:00 2001 From: viatrak Date: Thu, 7 Sep 2017 12:34:11 -0500 Subject: [PATCH 147/157] Redundant code to set_schema(). Make it DRY --- tenant_schemas/postgresql_backend/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index 6e940fa..a9c52a0 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -88,10 +88,7 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): """ Instructs to stay in the common 'public' schema. """ - self.tenant = FakeTenant(schema_name=get_public_schema_name()) - self.schema_name = get_public_schema_name() - self.set_settings_schema(self.schema_name) - self.search_path_set = False + self.set_schema(get_public_schema_name()) def set_settings_schema(self, schema_name): self.settings_dict['SCHEMA'] = schema_name From 86c4050856774fd36e7759336b5fa89109d155ee Mon Sep 17 00:00:00 2001 From: viatrak Date: Thu, 7 Sep 2017 12:41:12 -0500 Subject: [PATCH 148/157] Redundant code in set_tenant as set_schema(). Make it DRY --- tenant_schemas/postgresql_backend/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index a9c52a0..ad61f5b 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -67,11 +67,8 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): Main API method to current database schema, but it does not actually modify the db connection. """ + self.set_schema(tenant.schema_name, include_public) self.tenant = tenant - self.schema_name = tenant.schema_name - self.include_public_schema = include_public - self.set_settings_schema(self.schema_name) - self.search_path_set = False def set_schema(self, schema_name, include_public=True): """ From 7e104037c9522a408138c79c1fa8be3f98fc5894 Mon Sep 17 00:00:00 2001 From: viatrak Date: Thu, 7 Sep 2017 12:43:40 -0500 Subject: [PATCH 149/157] Content type cache should be cleared not just at the beginning of a request, but *anytime* the schema is changed (which happens in middle of requests). set_tenant() will also now result in a set_schema() call so it will claer_cache() at the beginning of a request, and on subsequent set_schema calls --- tenant_schemas/middleware.py | 9 --------- tenant_schemas/postgresql_backend/base.py | 9 +++++++++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index 639e686..c70c053 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -62,15 +62,6 @@ class BaseTenantMiddleware(MIDDLEWARE_MIXIN): request.tenant = tenant connection.set_tenant(request.tenant) - # Content type can no longer be cached as public and tenant schemas - # have different models. If someone wants to change this, the cache - # needs to be separated between public and shared schemas. If this - # cache isn't cleared, this can cause permission problems. For example, - # on public, a particular model has id 14, but on the tenants it has - # the id 15. if 14 is cached instead of 15, the permissions for the - # wrong model will be fetched. - ContentType.objects.clear_cache() - # Do we have a public-specific urlconf? if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF') and request.tenant.schema_name == get_public_schema_name(): request.urlconf = settings.PUBLIC_SCHEMA_URLCONF diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index ad61f5b..212d3ea 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -3,6 +3,7 @@ import warnings import psycopg2 from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured, ValidationError import django.db.utils @@ -80,6 +81,14 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): self.include_public_schema = include_public self.set_settings_schema(schema_name) self.search_path_set = False + # Content type can no longer be cached as public and tenant schemas + # have different models. If someone wants to change this, the cache + # needs to be separated between public and shared schemas. If this + # cache isn't cleared, this can cause permission problems. For example, + # on public, a particular model has id 14, but on the tenants it has + # the id 15. if 14 is cached instead of 15, the permissions for the + # wrong model will be fetched. + ContentType.objects.clear_cache() def set_schema_to_public(self): """ From 20c72782cee51a33fd5c56a0af7b2c653c1b6770 Mon Sep 17 00:00:00 2001 From: Michael Anckaert Date: Fri, 1 Dec 2017 09:52:16 +0100 Subject: [PATCH 150/157] Improved documentation on working with and creating tenants (#514) * Improved documentation on working with and creating tenants * Updated documentation according to requested changes --- docs/install.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/install.rst b/docs/install.rst index f539d5a..ed2b05f 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -184,6 +184,29 @@ globally. available globally. This helps avoid issues caused by hiding the public schema from queries. +Working with Tenant specific schemas +==================================== +Since each Tenant has it's own schema in the database you need a way to tell Django what +schema to use when using the management commands. + +A special management command ``tenant_command`` has been added to allow you to +execute Django management commands in the context of a specific Tenant schema. + +.. code-block:: python + + python manage.py tenant_command loaddata --schema=my_tenant test_fixture + +.. warning:: + + Depending on the configuration of your applications, the command you execute + may impact shared data also. + +Creating a new Tenant +===================== +See `Creating a new Tenant `_ for more details on how to create a new Tenant in our +application. + + Optional Settings ================= From 7239b91a47ed52c143fabd939353191fc58d1380 Mon Sep 17 00:00:00 2001 From: Guillaume Andreu Sabater Date: Wed, 6 Dec 2017 16:30:14 +0100 Subject: [PATCH 151/157] backported django postgres backend fixes --- .../migrations/0003_test_add_db_index.py | 20 ++++++ .../migrations/0004_test_alter_unique.py | 25 +++++++ .../postgresql_backend/introspection.py | 72 +++++++++++-------- 3 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py create mode 100644 dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py diff --git a/dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py b/dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py new file mode 100644 index 0000000..4f3f6fb --- /dev/null +++ b/dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('dts_test_app', '0002_test_drop_unique'), + ] + + operations = [ + migrations.AddField( + model_name='DummyModel', + name='indexed_value', + field=models.CharField(max_length=255, db_index=True), + ), + ] diff --git a/dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py b/dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py new file mode 100644 index 0000000..5f1828a --- /dev/null +++ b/dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('dts_test_app', '0003_test_add_db_index'), + ] + + operations = [ + migrations.AlterField( + model_name='DummyModel', + name='indexed_value', + field=models.CharField(max_length=255, unique=True), + ), + + migrations.RemoveField( + model_name='DummyModel', + name='indexed_value', + ), + ] diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index ca445d4..7ff2cca 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -5,6 +5,11 @@ from collections import namedtuple from django.db.backends.base.introspection import ( BaseDatabaseIntrospection, FieldInfo, TableInfo, ) +try: + # Django >= 1.11 + from django.db.models.indexes import Index +except ImportError: + Index = None from django.utils.encoding import force_text fields = FieldInfo._fields @@ -113,7 +118,8 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): FROM pg_attribute AS fka JOIN pg_class AS fkc ON fka.attrelid = fkc.oid WHERE fka.attrelid = c.confrelid - AND fka.attnum = c.confkey[1]) + AND fka.attnum = c.confkey[1]), + cl.reloptions FROM pg_constraint AS c JOIN pg_class AS cl ON c.conrelid = cl.oid JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid @@ -135,35 +141,37 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): _get_index_constraints_query = """ SELECT - indexname, array_agg(attname), indisunique, indisprimary, - array_agg(ordering), amname, exprdef + indexname, array_agg(attname ORDER BY rnum), indisunique, indisprimary, + array_agg(ordering ORDER BY rnum), amname, exprdef, s2.attoptions + FROM ( + SELECT + row_number() OVER () as rnum, c2.relname as indexname, + idx.*, attr.attname, am.amname, + CASE + WHEN idx.indexprs IS NOT NULL THEN + pg_get_indexdef(idx.indexrelid) + END AS exprdef, + CASE am.amname + WHEN 'btree' THEN + CASE (option & 1) + WHEN 1 THEN 'DESC' ELSE 'ASC' + END + END as ordering, + c2.reloptions as attoptions FROM ( SELECT - c2.relname as indexname, idx.*, attr.attname, am.amname, - CASE - WHEN idx.indexprs IS NOT NULL THEN - pg_get_indexdef(idx.indexrelid) - END AS exprdef, - CASE am.amname - WHEN 'btree' THEN - CASE (option & 1) - WHEN 1 THEN 'DESC' ELSE 'ASC' - END - END as ordering - FROM ( - SELECT - *, unnest(i.indkey) as key, unnest(i.indoption) as option - FROM pg_index i - ) idx - LEFT JOIN pg_class c ON idx.indrelid = c.oid - LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid - LEFT JOIN pg_am am ON c2.relam = am.oid - LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key - LEFT JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relname = %(table)s - AND n.nspname = %(schema)s - ) s2 - GROUP BY indexname, indisunique, indisprimary, amname, exprdef; + *, unnest(i.indkey) as key, unnest(i.indoption) as option + FROM pg_index i + ) idx + LEFT JOIN pg_class c ON idx.indrelid = c.oid + LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid + LEFT JOIN pg_am am ON c2.relam = am.oid + LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key + LEFT JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.relname = %(table)s + AND n.nspname = %(schema)s + ) s2 + GROUP BY indexname, indisunique, indisprimary, amname, exprdef, attoptions; """ def get_field_type(self, data_type, description): @@ -274,7 +282,7 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): 'table': table_name, }) - for constraint, columns, kind, used_cols in cursor.fetchall(): + for constraint, columns, kind, used_cols, options in cursor.fetchall(): constraints[constraint] = { "columns": columns, "primary_key": kind == "p", @@ -283,6 +291,7 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): "check": kind == "c", "index": False, "definition": None, + "options": options, } # Now get indexes @@ -291,7 +300,7 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): 'table': table_name, }) - for index, columns, unique, primary, orders, type_, definition in cursor.fetchall(): + for index, columns, unique, primary, orders, type_, definition, options in cursor.fetchall(): if index not in constraints: constraints[index] = { "columns": columns if columns != [None] else [], @@ -301,7 +310,8 @@ class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): "foreign_key": None, "check": False, "index": True, - "type": type_, + "type": Index.suffix if type_ == 'btree' and Index else type_, "definition": definition, + "options": options, } return constraints From 1c577863877c9aea27ab79a928c0daac15b8c5f9 Mon Sep 17 00:00:00 2001 From: Abhishek Menon Date: Mon, 27 Nov 2017 17:50:57 -0500 Subject: [PATCH 152/157] Add missing `post_schema_sync` signal documentation I am adding the `post_schema_sync` signal documentation which was missing. Right now the sender is TenantMixin. Although I believe it's better to make the Tenant Model itself as the sender, if that is possible. Your thoughts? --- docs/use.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/use.rst b/docs/use.rst index e2c98f2..1cab55a 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -272,6 +272,27 @@ Returns the name of the public schema (from settings or the default ``public``). Returns the ``TENANT_LIMIT_SET_CALLS`` setting or the default (``False``). See below. +Signals +------- + +If you want to perform operations after creating a tenant and it's schema is saved and synced, you won't be able to use the built-in ``post_save`` signal, as it sends the signal immediately after the model is saved. + +For this purpose, we have provided a ``post_schema_sync`` signal, which is available in ``tenant_schemas.signals`` + +.. code-block:: python + + + from tenant_schemas.signals import post_schema_sync + from tenant_schemas.models import TenantMixin + + def foo_bar(sender, tenant, **kwargs): + ... + #This function will run after the tenant is saved, its schema created and synced. + ... + + post_schema_sync.connect(foo_bar, sender=TenantMixin) + + Logging ------- From eb0a27e712693879a3cb486299a79a8de59420d9 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Sat, 6 Feb 2016 15:03:10 +0100 Subject: [PATCH 153/157] Replace the cache dictionnary by a thread local variable (~ #9891) This makes it safe to use multitenancy with threads. --- tenant_schemas/postgresql_backend/base.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index 212d3ea..a8d3db5 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -1,9 +1,10 @@ import re import warnings import psycopg2 +import threading from django.conf import settings -from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.core.exceptions import ImproperlyConfigured, ValidationError import django.db.utils @@ -173,3 +174,13 @@ class FakeTenant: """ def __init__(self, schema_name): self.schema_name = schema_name + + +# Make the ContentType cache tenant and thread safe +ContentTypeManager._thread_local_cache = threading.local() +class ContentTypeCacheDescriptor(object): + def __get__(self, obj): + if not hasattr(obj._thread_local_cache, '_cache'): + obj._thread_local_cache._cache = {} + return obj._thread_local_cache._cache +ContentTypeManager._cache = ContentTypeCacheDescriptor() From b3914c84cfa7edfdadc6c99a57478d46b0680603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Wed, 16 Mar 2016 11:05:41 +0100 Subject: [PATCH 154/157] fix custom content type cache __get__ method (#10308) --- tenant_schemas/postgresql_backend/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index a8d3db5..2bc836d 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -179,8 +179,8 @@ class FakeTenant: # Make the ContentType cache tenant and thread safe ContentTypeManager._thread_local_cache = threading.local() class ContentTypeCacheDescriptor(object): - def __get__(self, obj): - if not hasattr(obj._thread_local_cache, '_cache'): - obj._thread_local_cache._cache = {} - return obj._thread_local_cache._cache + def __get__(self, obj, owner): + if not hasattr(owner._thread_local_cache, '_cache'): + owner._thread_local_cache._cache = {} + return owner._thread_local_cache._cache ContentTypeManager._cache = ContentTypeCacheDescriptor() From 08bfb7515e88da2d0c6de4e667cbbfeac6c4bf42 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 16 Mar 2016 13:42:24 +0100 Subject: [PATCH 155/157] setup.py: use our own get_version() --- setup.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 6b8613a..4afa103 100755 --- a/setup.py +++ b/setup.py @@ -1,17 +1,36 @@ #!/usr/bin/env python +import os +import subprocess from os.path import exists -from version import get_git_version - try: from setuptools import setup except ImportError: from distutils.core import setup +def get_version(): + '''Use the VERSION, if absent generates a version with git describe, if not + tag exists, take 0.0.0- and add the length of the commit log. + ''' + if os.path.exists('VERSION'): + with open('VERSION', 'r') as v: + return v.read() + if os.path.exists('.git'): + p = subprocess.Popen(['git', 'describe', '--dirty', '--match=v*'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + result = p.communicate()[0] + if p.returncode == 0: + result = result.split()[0][1:] + else: + result = '0.0.0-%s' % len(subprocess.check_output( + ['git', 'rev-list', 'HEAD']).splitlines()) + return result.replace('-', '.').replace('.g', '+g') + return '0.0.0' + setup( name='django-tenant-schemas', - version=get_git_version(), + version=get_version(), author='Bernardo Pires Carneiro', author_email='carneiro.be@gmail.com', packages=[ From d8f7ca69e94b5635c3d49c30b7aefae72ae55645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Schneider?= Date: Tue, 14 Oct 2014 11:46:35 +0200 Subject: [PATCH 156/157] debian: initial import --- changelog | 5 +++++ compat | 1 + control | 17 +++++++++++++++ files | 2 ++ ...n-django-tenant-schemas.postinst.debhelper | 7 +++++++ python-django-tenant-schemas.prerm.debhelper | 12 +++++++++++ python-django-tenant-schemas.substvars | 4 ++++ ...3-django-tenant-schemas.postinst.debhelper | 7 +++++++ python3-django-tenant-schemas.prerm.debhelper | 10 +++++++++ python3-django-tenant-schemas.substvars | 2 ++ rules | 21 +++++++++++++++++++ source/format | 1 + source/options | 1 + 13 files changed, 90 insertions(+) create mode 100644 changelog create mode 100644 compat create mode 100644 control create mode 100644 files create mode 100644 python-django-tenant-schemas.postinst.debhelper create mode 100644 python-django-tenant-schemas.prerm.debhelper create mode 100644 python-django-tenant-schemas.substvars create mode 100644 python3-django-tenant-schemas.postinst.debhelper create mode 100644 python3-django-tenant-schemas.prerm.debhelper create mode 100644 python3-django-tenant-schemas.substvars create mode 100755 rules create mode 100644 source/format create mode 100644 source/options diff --git a/changelog b/changelog new file mode 100644 index 0000000..5140116 --- /dev/null +++ b/changelog @@ -0,0 +1,5 @@ +django-tenant-schemas (1.4.8-1) unstable; urgency=low + + * source package automatically created by stdeb 0.8.2 + + -- Jérôme Schneider Tue, 14 Oct 2014 11:39:16 +0200 diff --git a/compat b/compat new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/compat @@ -0,0 +1 @@ +7 diff --git a/control b/control new file mode 100644 index 0000000..68f794f --- /dev/null +++ b/control @@ -0,0 +1,17 @@ +Source: django-tenant-schemas +Maintainer: Jérôme Schneider +Section: python +Priority: optional +Build-Depends: python-setuptools (>= 0.6b3), python3-setuptools, python-all (>= 2.6.6-3), python3-all, debhelper (>= 7) +Standards-Version: 3.9.1 + +Package: python-django-tenant-schemas +Architecture: all +Depends: ${misc:Depends}, ${python:Depends} +Description: Tenant support for Django using PostgreSQL schemas. + +Package: python3-django-tenant-schemas +Architecture: all +Depends: ${misc:Depends}, ${python3:Depends} +Description: Tenant support for Django using PostgreSQL schemas. + diff --git a/files b/files new file mode 100644 index 0000000..7a45648 --- /dev/null +++ b/files @@ -0,0 +1,2 @@ +python-django-tenant-schemas_1.4.8-10-ga5f144a-1_all.deb python optional +python3-django-tenant-schemas_1.4.8-10-ga5f144a-1_all.deb python optional diff --git a/python-django-tenant-schemas.postinst.debhelper b/python-django-tenant-schemas.postinst.debhelper new file mode 100644 index 0000000..79168ea --- /dev/null +++ b/python-django-tenant-schemas.postinst.debhelper @@ -0,0 +1,7 @@ + +# Automatically added by dh_python2: +if which pycompile >/dev/null 2>&1; then + pycompile -p python-django-tenant-schemas +fi + +# End automatically added section diff --git a/python-django-tenant-schemas.prerm.debhelper b/python-django-tenant-schemas.prerm.debhelper new file mode 100644 index 0000000..5575e82 --- /dev/null +++ b/python-django-tenant-schemas.prerm.debhelper @@ -0,0 +1,12 @@ + +# Automatically added by dh_python2: +if which pyclean >/dev/null 2>&1; then + pyclean -p python-django-tenant-schemas +else + dpkg -L python-django-tenant-schemas | grep \.py$ | while read file + do + rm -f "${file}"[co] >/dev/null + done +fi + +# End automatically added section diff --git a/python-django-tenant-schemas.substvars b/python-django-tenant-schemas.substvars new file mode 100644 index 0000000..606bc03 --- /dev/null +++ b/python-django-tenant-schemas.substvars @@ -0,0 +1,4 @@ +python:Versions=2.7 +python:Provides=python2.7-django-tenant-schemas +python:Depends=python (>= 2.7), python (<< 2.8), python:any (>= 2.6.6-7~), python-django, python-psycopg2 +misc:Depends= diff --git a/python3-django-tenant-schemas.postinst.debhelper b/python3-django-tenant-schemas.postinst.debhelper new file mode 100644 index 0000000..bff23bb --- /dev/null +++ b/python3-django-tenant-schemas.postinst.debhelper @@ -0,0 +1,7 @@ + +# Automatically added by dhpython: +if which py3compile >/dev/null 2>&1; then + py3compile -p python3-django-tenant-schemas +fi + +# End automatically added section diff --git a/python3-django-tenant-schemas.prerm.debhelper b/python3-django-tenant-schemas.prerm.debhelper new file mode 100644 index 0000000..91bb179 --- /dev/null +++ b/python3-django-tenant-schemas.prerm.debhelper @@ -0,0 +1,10 @@ + +# Automatically added by dhpython: +if which py3clean >/dev/null 2>&1; then + py3clean -p python3-django-tenant-schemas +else + dpkg -L python3-django-tenant-schemas | perl -ne 's,/([^/]*)\.py$,/__pycache__/\1.*, or next; unlink $_ or die $! foreach glob($_)' + find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir +fi + +# End automatically added section diff --git a/python3-django-tenant-schemas.substvars b/python3-django-tenant-schemas.substvars new file mode 100644 index 0000000..1cf1595 --- /dev/null +++ b/python3-django-tenant-schemas.substvars @@ -0,0 +1,2 @@ +python3:Depends=python3-psycopg2, python3-django, python3:any (>= 3.3.2-2~) +misc:Depends= diff --git a/rules b/rules new file mode 100755 index 0000000..dab3fe0 --- /dev/null +++ b/rules @@ -0,0 +1,21 @@ +#!/usr/bin/make -f + +# This file was automatically generated by stdeb 0.8.2 at +# Tue, 14 Oct 2014 11:39:16 +0200 + +%: + dh $@ --with python2,python3 --buildsystem=python_distutils + +override_dh_auto_clean: + python setup.py clean -a + python3 setup.py clean -a + find . -name \*.pyc -exec rm {} \; + +override_dh_auto_build: + python setup.py build --force + python3 setup.py build --force + +override_dh_auto_install: + python setup.py install --force --root=debian/python-django-tenant-schemas --no-compile -O0 --install-layout=deb + python3 setup.py install --force --root=debian/python3-django-tenant-schemas --no-compile -O0 --install-layout=deb + diff --git a/source/format b/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/source/options b/source/options new file mode 100644 index 0000000..bcc4bbb --- /dev/null +++ b/source/options @@ -0,0 +1 @@ +extend-diff-ignore="\.egg-info$" \ No newline at end of file From 29660eaaeb4ce838d607b6d07fc4f4129f75232c Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 16 Oct 2014 15:32:59 +0200 Subject: [PATCH 157/157] debian: only build for python2 --- control | 8 +------- rules | 16 +--------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/control b/control index 68f794f..96664ed 100644 --- a/control +++ b/control @@ -2,16 +2,10 @@ Source: django-tenant-schemas Maintainer: Jérôme Schneider Section: python Priority: optional -Build-Depends: python-setuptools (>= 0.6b3), python3-setuptools, python-all (>= 2.6.6-3), python3-all, debhelper (>= 7) +Build-Depends: python-setuptools (>= 0.6b3), python-all (>= 2.6.6-3), debhelper (>= 7) Standards-Version: 3.9.1 Package: python-django-tenant-schemas Architecture: all Depends: ${misc:Depends}, ${python:Depends} Description: Tenant support for Django using PostgreSQL schemas. - -Package: python3-django-tenant-schemas -Architecture: all -Depends: ${misc:Depends}, ${python3:Depends} -Description: Tenant support for Django using PostgreSQL schemas. - diff --git a/rules b/rules index dab3fe0..b2e2109 100755 --- a/rules +++ b/rules @@ -4,18 +4,4 @@ # Tue, 14 Oct 2014 11:39:16 +0200 %: - dh $@ --with python2,python3 --buildsystem=python_distutils - -override_dh_auto_clean: - python setup.py clean -a - python3 setup.py clean -a - find . -name \*.pyc -exec rm {} \; - -override_dh_auto_build: - python setup.py build --force - python3 setup.py build --force - -override_dh_auto_install: - python setup.py install --force --root=debian/python-django-tenant-schemas --no-compile -O0 --install-layout=deb - python3 setup.py install --force --root=debian/python3-django-tenant-schemas --no-compile -O0 --install-layout=deb - + dh $@ --with python2