Merge remote-tracking branch 'github/master'

Conflicts:
	tenant_schemas/postgresql_backend/base.py
This commit is contained in:
Thomas NOËL 2015-01-30 16:44:56 +01:00
commit c523006c61
46 changed files with 972 additions and 382 deletions

View File

@ -7,6 +7,7 @@ include version.py
include VERSION
recursive-include docs *
recursive-include examples *
recursive-include dts_test_project *
# exclude all bytecode
global-exclude *.pyo

View File

@ -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,6 +125,16 @@ 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
being synced (shared or tenant).
::
DATABASE_ROUTERS = (
'tenant_schemas.routers.TenantSyncRouter',
)
Add ``tenant_schemas`` to your ``INSTALLED_APPS``.
@ -196,4 +206,4 @@ tenant specific apps. Complete instructions can be found at
.. |PyPi downloads| image:: https://pypip.in/d/django-tenant-schemas/badge.png
:target: https://crate.io/packages/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/
.. _django-tenant-schemas.readthedocs.org: https://django-tenant-schemas.readthedocs.org/en/latest/

View File

@ -21,6 +21,14 @@ Your ``DATABASE_ENGINE`` setting needs to be changed to
# ..
}
}
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
DATABASE_ROUTERS = (
'tenant_schemas.routers.TenantSyncRouter',
)
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.
@ -31,7 +39,7 @@ Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the top of
#...
)
Make sure you have ``django.core.context_processors.request`` listed under ``TEMPLATE_CONTEXT_PROCESSORS`` else the tenant will not be available at ``request``.
Make sure you have ``django.core.context_processors.request`` listed under ``TEMPLATE_CONTEXT_PROCESSORS`` else the tenant will not be available on ``request``.
.. code-block:: python
@ -42,7 +50,7 @@ Make sure you have ``django.core.context_processors.request`` listed under ``TEM
The Tenant Model
================
Now we have to create your tenant model. To allow the flexibility of having any data in you want in your tenant, we have a mixin called ``TenantMixin`` which you **have to** inherit from. 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``.
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``.
.. code-block:: python
@ -60,7 +68,7 @@ Now we have to create your tenant model. To allow the flexibility of having any
Configure Tenant and Shared Applications
========================================
By default all apps will be synced to your ``public`` schema and to your tenant schemas. If you want to make use of shared and tenant-specific applications, there are two additional 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 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
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
@ -87,11 +95,7 @@ By default all apps will be synced to your ``public`` schema and to your tenant
'myapp.houses',
)
INSTALLED_APPS = SHARED_APPS + TENANT_APPS
.. warning::
As of now it's not possible to have a centralized ``django.contrib.auth``.
INSTALLED_APPS = list(set(SHARED_APPS + TENANT_APPS))
You also have to set where your tenant model is.
@ -99,15 +103,19 @@ You also have to set where your tenant model is.
TENANT_MODEL = "customers.Client" # app.Model
Now run ``sync_schemas``, 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`` (``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.
.. code-block:: bash
# Django >= 1.7
python manage.py migrate_schemas --shared
# Django < 1.7
python manage.py sync_schemas --shared
.. warning::
Never use ``syncdb`` as it would sync *all* your apps to ``public``!
Never use ``migrate`` or ``syncdb`` 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 <use>`.
@ -129,7 +137,7 @@ globally.
South Migrations
================
This app supports `South <http://south.aeracode.org/>`_ so if you haven't configured it yet and would like to:
If you're on Django 1.6 or older, this app supports `South <http://south.aeracode.org/>`_ so if you haven't configured it yet and would like to:
For Django 1.1 or below
@ -162,7 +170,7 @@ Optional Settings
:Default: ``'public'``
The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be installed.
The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created.
.. attribute:: TENANT_CREATION_FAKES_MIGRATIONS
@ -202,7 +210,7 @@ If your projects are ran using a WSGI configuration, this can be done by creatin
If you put this in the same Django project, you can make a new ``settings_public.py`` which points to a different ``urls_public.py``. This has the advantage that you can use the same apps that you use for your tenant websites.
Or you can create a completely separate project for the main website, but be aware that if you specify a PostgreSQL database in the ``DATABASES`` setting in ``settings.py``, Django will use its default ``public`` schema as `described in the PostgreSQL documentation <http://www.postgresql.org/docs/9.2/static/ddl-schemas.html#DDL-SCHEMAS-PUBLIC>`_.
Or you can create a completely separate project for the main website.
Configuring your Apache Server (optional)
=========================================

View File

@ -3,9 +3,4 @@ Get Involved!
=============
Suggestions, bugs, ideas, patches, questions
--------------------------------------------
Are **highly** welcome! Feel free to write an issue for any feedback you have. :)
Multi-Threading
---------------
This is being used right now in production on a small project and I have made an attempt to make it thread-safe, but I'm a complete beginner at this subject. Any help on this would be **HIGHLY** appreciated. Can someone please check if the custom `postgresql_backend <https://github.com/bernardopires/django-tenant-schemas/blob/master/tenant_schemas/postgresql_backend/base.py>`_ is thread-safe? If there is a way to write a test for this, it would be awesome. Please send in your feedback at `issue #2 <https://github.com/bernardopires/django-tenant-schemas/issues/2>`_.
Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub <https://github.com/bernardopires/django-tenant-schemas>`. :)

View File

@ -3,7 +3,7 @@ Tests
==================
Running the tests
-----------------
If you're using South, don't forget to set ``SOUTH_TESTS_MIGRATE = False``.
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``.
.. code-block:: bash

View File

@ -3,7 +3,7 @@ Using django-tenant-schemas
===========================
Creating a Tenant
-----------------
This 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
@ -31,7 +31,7 @@ Now we can create our first real tenant.
on_trial=True)
tenant.save() # sync_schemas automatically called, your tenant is ready to be used!
Because you have the tenant middleware installed, any request made to ``tenant.my-domain.com`` will now automatically set your PostgreSQL's ``search_path`` to ``tenant1`` and ``public``, making shared apps available too. The tenant will be made available at ``request.tenant``. By the way, the current schema is also available at ``connection.schema_name``, which is useful, for example, if you want to hook to any of django's signals.
Because you have the tenant middleware installed, any request made to ``tenant.my-domain.com`` will now automatically set your PostgreSQL's ``search_path`` to ``tenant1, public``, making shared apps available too. The tenant will be made available at ``request.tenant``. By the way, the current schema is also available at ``connection.schema_name``, which is useful, for example, if you want to hook to any of django's signals.
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.
@ -43,6 +43,9 @@ Every command except tenant_command runs by default on all tenants. You can also
./manage.py sync_schemas --schema=customer1
sync_schemas
~~~~~~~~~~~~
The command ``sync_schemas`` is the most important command on this app. The way it works is that it calls Django's ``syncdb`` in two different ways. First, it calls ``syncdb`` for the ``public`` schema, only syncing the shared apps. Then it runs ``syncdb`` for every tenant in the database, this time only syncing the tenant apps.
.. warning::
@ -61,6 +64,9 @@ You can also use the option ``--tenant`` to only sync tenant apps or ``--shared`
./manage.py sync_schemas --shared # will only sync the public schema
migrate_schemas
~~~~~~~~~~~~~~~
We've also packed south's migrate command in a compatible way with this app. It will also respect the ``SHARED_APPS`` and ``TENANT_APPS`` settings, so if you're migrating the ``public`` schema it will only migrate ``SHARED_APPS``. If you're migrating tenants, it will only migrate ``TENANT_APPS``.
.. code-block:: bash
@ -81,23 +87,35 @@ Or
in case you're just switching your ``myapp`` application to use South migrations.
tenant_command
~~~~~~~~~~~~~~
To run any command on an individual schema, you can use the special ``tenant_command``, which creates a wrapper around your command so that it only runs on the schema you specify. For example
.. code-block:: bash
./manage.py tenant_command createsuperuser
./manage.py tenant_command loaddata
If you don't specify a schema, you will be prompted to enter one. Otherwise, you may specify a schema preemptively
.. code-block:: bash
./manage.py tenant_command createsuperuser --schema=customer1
./manage.py tenant_command loaddata --schema=customer1
createsuperuser
~~~~~~~~~~~~~~~
The command ``createsuperuser`` is already automatically wrapped to have a ``schema`` flag. Create a new super user with
.. code-block:: bash
./manage.py createsuperuser --username='admin' --schema=customer1
Performance Considerations
--------------------------
The hook for ensuring the schema search_path is set properly is the ``DatabaseWrapper`` method ``_cursor()``, which sets the path on every database operation. However, in a high volume environment, this can take considerable time. A flag, ``TENANT_LIMIT_SET_CALLS``, is available to keep the number of calls to a minimum. The flag may be set in ``settings.py`` as follows:
The hook for ensuring the ``search_path`` is set properly happens inside the ``DatabaseWrapper`` method ``_cursor()``, which sets the path on every database operation. However, in a high volume environment, this can take considerable time. A flag, ``TENANT_LIMIT_SET_CALLS``, is available to keep the number of calls to a minimum. The flag may be set in ``settings.py`` as follows:
.. code-block:: python

View File

View File

@ -0,0 +1,8 @@
from django.db import models
from tenant_schemas.models import TenantMixin
class Client(TenantMixin):
name = models.CharField(max_length=100)
description = models.TextField(max_length=200)
created_on = models.DateField(auto_now_add=True)

View File

@ -0,0 +1,16 @@
from django.contrib.auth.models import User
from django.db import models
class DummyModel(models.Model):
"""
Just a test model so we can test manipulating data inside a tenant
"""
name = models.CharField(max_length=100)
def __unicode__(self):
return self.name
class ModelWithFkToPublicUser(models.Model):
user = models.ForeignKey(User)

View File

@ -0,0 +1,124 @@
"""
Django settings for dts_test_project project.
For more information on this file, see
https://docs.djangoproject.com/en/1.6/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.6/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__))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.6/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'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
TEMPLATE_DEBUG = True
ALLOWED_HOSTS = []
# Application definition
SHARED_APPS = (
'tenant_schemas', # mandatory
'customers', # you must list the app where your tenant model resides in
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
)
TENANT_APPS = (
'dts_test_app',
)
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',)
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
ROOT_URLCONF = 'dts_test_project.urls'
WSGI_APPLICATION = 'dts_test_project.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.6/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'tenant_schemas.postgresql_backend',
'NAME': 'dts_test_project',
'USER': 'postgres',
'PASSWORD': 'root',
'HOST': 'localhost',
'PORT': '',
}
}
DATABASE_ROUTERS = (
'tenant_schemas.routers.TenantSyncRouter',
)
MIDDLEWARE_CLASSES = (
'tenant_tutorial.middleware.TenantTutorialMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
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',
)
# Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.6/howto/static-files/
STATIC_URL = '/static/'

10
dts_test_project/manage.py Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dts_test_project.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@ -2,4 +2,4 @@ from django import forms
class GenerateUsersForm(forms.Form):
pass
pass

View File

@ -5,4 +5,4 @@ from tenant_schemas.models import TenantMixin
class Client(TenantMixin):
name = models.CharField(max_length=100)
description = models.TextField(max_length=200)
created_on = models.DateField(auto_now_add=True)
created_on = models.DateField(auto_now_add=True)

View File

@ -18,7 +18,11 @@
</ul><br>
<p>Just run the command below on your shell to sync <code>SHARED_APPS</code>. Make sure your environment
has <code>Django</code> and <code>django-tenant-schemas</code> available.</p>
{% if DJANGO17 %}
<pre>$ python manage.py migrate_schemas --shared</pre>
{% else %}
<pre>$ python manage.py sync_schemas --shared</pre>
{% endif %}
<p>When you're done refresh this page.</p>
{% elif no_public_tenant %}
<h2>Second Step: Create a public tenant</h2>
@ -81,7 +85,18 @@ Client(domain_url='tenant1.trendy-sass.com',
description='Our first real tenant, awesome!').save()</pre>
<p>Saving a tenant that didn't exist before will create their schema and sync <code>TENANT_APPS</code> automatically. You should see
the following lines as the result.</p>
<pre>=== Running syncdb for schema: tenant1
{% if DJANGO17 %}<pre>Operations to perform:
Synchronize unmigrated apps: customers, tenant_schemas
Apply all migrations: contenttypes, auth, sessions
Synchronizing apps without migrations:
Creating tables...
Installing custom SQL...
Installing indexes...
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying sessions.0001_initial... OK</pre>
{% else %}<pre>=== Running syncdb for schema: tenant1
Creating tables ...
Creating table auth_permission
Creating table auth_group_permissions
@ -93,6 +108,7 @@ Creating table django_content_type
Installing custom SQL ...
Installing indexes ...
Installed 0 object(s) from 0 fixture(s)</pre>
{% endif %}
<p>This means your tenant was installed successfully. Now create the second tenant.</p>
<pre>
Client(domain_url='tenant2.trendy-sass.com',

View File

@ -78,7 +78,6 @@ STATICFILES_DIRS = (
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
)
# Make this unique, and don't share it with anybody.
@ -88,9 +87,14 @@ SECRET_KEY = 'as-%*_93v=r5*p_7cu8-%o6b&x^g+q$#*e*fl)k)x0-t=%q0qa'
TEMPLATE_LOADERS = (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
# 'django.template.loaders.eggs.Loader',
)
DATABASE_ROUTERS = (
'tenant_schemas.routers.TenantSyncRouter',
)
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
MIDDLEWARE_CLASSES = (
'tenant_tutorial.middleware.TenantTutorialMiddleware',
'django.middleware.common.CommonMiddleware',
@ -139,7 +143,11 @@ TENANT_APPS = (
TENANT_MODEL = "customers.Client" # app.Model
INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',)
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',)
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer'

View File

@ -1,6 +1,6 @@
from django.conf.urls import patterns
from django.conf.urls import patterns, url
from tenant_tutorial.views import HomeView
urlpatterns = patterns('',
(r'^$', HomeView.as_view()),
)
url(r'^$', HomeView.as_view()),
)

View File

@ -1,6 +1,6 @@
from django.conf.urls import patterns
from django.conf.urls import patterns, url
from customers.views import TenantView
urlpatterns = patterns('',
(r'^$', TenantView.as_view()),
)
url(r'^$', TenantView.as_view()),
)

View File

@ -1,7 +1,8 @@
import django
from django.conf import settings
from django.db import utils
from django.views.generic import TemplateView
from tenant_schemas.utils import remove_www_and_dev
from tenant_schemas.utils import remove_www
from customers.models import Client
@ -11,7 +12,11 @@ class HomeView(TemplateView):
def get_context_data(self, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)
hostname_without_port = remove_www_and_dev(self.request.get_host().split(':')[0])
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:
@ -27,4 +32,4 @@ class HomeView(TemplateView):
context['only_public_tenant'] = True
context['tenants_list'] = Client.objects.all()
return context
return context

View File

@ -20,6 +20,7 @@ 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',

View File

@ -1,3 +1,4 @@
import django
import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@ -5,29 +6,40 @@ 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 like this:
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.
This is necessary to overwrite built-in django management commands with
their schema-aware implementations.
"""
# Make a bunch of tests for configuration recommendations
# These are best practices basically, to avoid hard to find bugs, unexpected behaviour
# 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?")
raise ImproperlyConfigured("TENANT_APPS is empty. "
"Maybe you don't need this app?")
if settings.INSTALLED_APPS[-1] != 'tenant_schemas':
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'.")
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())
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))
raise ImproperlyConfigured(
"Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS."
% list(invalid_schemas))

View File

@ -3,4 +3,4 @@ from django.contrib.auth.management.commands import createsuperuser
class Command(TenantWrappedCommand):
COMMAND = createsuperuser.Command
COMMAND = createsuperuser.Command

View File

@ -0,0 +1,3 @@
"""
Commands for Django <=1.6 (before south was integrated to Django).
"""

View File

@ -0,0 +1,88 @@
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()

View File

@ -1,5 +1,8 @@
import django
from django.conf import settings
from django.core.management.base import CommandError, BaseCommand
from tenant_schemas.utils import django_is_in_test_mode
try:
from south.management.commands.migrate import Command as MigrateCommand
except ImportError:
@ -12,7 +15,12 @@ class Command(MigrateCommand):
database = options.get('database', 'default')
if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' or
MigrateCommand is BaseCommand):
raise CommandError("migrate has been disabled, for database '{}'. Use migrate_schemas "
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))
super(Command, self).handle(*args, **options)
if django.VERSION >= (1, 7, 0) and django_is_in_test_mode():
from .migrate_schemas import MigrateSchemasCommand
Command = MigrateSchemasCommand

View File

@ -1,86 +1,71 @@
from django.conf import settings
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 south import migration
from south.migration.base import Migrations
from south.management.commands.migrate import Command as MigrateCommand
from django.conf import settings
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
class Command(SyncCommon):
help = "Migrate schemas with South"
option_list = MigrateCommand.option_list + SyncCommon.option_list
class MigrateSchemasCommand(SyncCommon):
help = "Updates database schema. Manages both apps with migrations and those without."
def run_from_argv(self, argv):
"""
Changes the option_list to use the options from the wrapped command.
Adds schema parameter to specify which schema will be used when
executing the wrapped command.
"""
self.option_list += MigrateCommand.option_list
super(MigrateSchemasCommand, self).run_from_argv(argv)
def handle(self, *args, **options):
super(Command, self).handle(*args, **options)
super(MigrateSchemasCommand, self).handle(*args, **options)
self.PUBLIC_SCHEMA_NAME = get_public_schema_name()
if self.sync_public and not self.schema_name:
self.schema_name = self.PUBLIC_SCHEMA_NAME
if self.sync_public:
self.migrate_public_apps()
self.run_migrations(self.schema_name, settings.SHARED_APPS)
if self.sync_tenant:
self.migrate_tenant_apps(self.schema_name)
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(
self.schema_name))
else:
self.run_migrations(self.schema_name, settings.TENANT_APPS)
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 _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)
def run_migrations(self, schema_name, included_apps):
self._notice("=== Running migrate for schema %s" % schema_name)
connection.set_schema(schema_name)
command = MigrateCommand()
for app in ignored_apps:
app_label = app.split('.')[-1]
settings.SOUTH_MIGRATION_MODULES[app_label] = 'ignore'
defaults = {}
for opt in MigrateCommand.option_list:
if opt.dest in self.options:
defaults[opt.dest] = self.options[opt.dest]
elif opt.default is NO_DEFAULT:
defaults[opt.dest] = None
else:
defaults[opt.dest] = opt.default
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()
command.execute(*self.args, **defaults)
connection.set_schema_to_public()
def _restore_south_settings(self):
settings.SOUTH_MIGRATION_MODULES = self._old_south_modules
def _notice(self, output):
self.stdout.write(self.style.NOTICE(output))
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=False)
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()
if django.VERSION >= (1, 7, 0):
Command = MigrateSchemasCommand
else:
from .legacy.migrate_schemas import Command

View File

@ -1,5 +1,7 @@
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
@ -15,15 +17,16 @@ class Command(SyncCommon):
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
# save original settings
for model in get_models(include_auto_created=True):
setattr(model._meta, 'was_managed', model._meta.managed)
# Content types may be different on tenants, so reset the cache
ContentType.objects.clear_cache()
if self.sync_public:
@ -31,32 +34,12 @@ class Command(SyncCommon):
if self.sync_tenant:
self.sync_tenant_apps(self.schema_name)
# restore settings
for model in get_models(include_auto_created=True):
model._meta.managed = model._meta.was_managed
def _set_managed_apps(self, included_apps):
""" sets which apps are managed by syncdb """
for model in get_models(include_auto_created=True):
model._meta.managed = False
verbosity = int(self.options.get('verbosity'))
for app_model in get_apps():
app_name = app_model.__name__.replace('.models', '')
if app_name in included_apps:
for model in get_models(app_model, include_auto_created=True):
model._meta.managed = model._meta.was_managed
if model._meta.managed and verbosity >= 3:
self._notice("=== Include Model: %s: %s" % (app_name, model.__name__))
def _sync_tenant(self, tenant):
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):
apps = self.tenant_apps or self.installed_apps
self._set_managed_apps(apps)
if schema_name:
tenant = get_tenant_model().objects.filter(schema_name=schema_name).get()
self._sync_tenant(tenant)
@ -69,7 +52,5 @@ class Command(SyncCommon):
self._sync_tenant(tenant)
def sync_public_apps(self):
apps = self.shared_apps or self.installed_apps
self._set_managed_apps(apps)
SyncdbCommand().execute(**self.options)
self._notice("=== Running syncdb for schema public")

View File

@ -14,7 +14,7 @@ class Command(syncdb.Command):
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 '{}'. "
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))

View File

@ -2,14 +2,15 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.shortcuts import get_object_or_404
from tenant_schemas.utils import get_tenant_model, remove_www, get_public_schema_name
from tenant_schemas.utils import (get_tenant_model, remove_www,
get_public_schema_name)
class TenantMiddleware(object):
"""
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...
various ways which is better than corrupting or revealing data.
"""
def hostname_from_request(self, request):
""" Extracts hostname from request. Used for custom requests filtering.
@ -18,22 +19,24 @@ class TenantMiddleware(object):
return remove_www(request.get_host().split(':')[0])
def process_request(self, request):
# connection needs first to be at the public schema, as this is where the
# tenant metadata is stored
# 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)
request.tenant = get_object_or_404(get_tenant_model(), domain_url=hostname)
request.tenant = get_object_or_404(
get_tenant_model(), domain_url=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 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.
# 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 token?
# 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

View File

@ -1,6 +1,8 @@
import django
from django.conf import settings
from django.db import models, connection, transaction
from django.db import models, connection
from django.core.management import call_command
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
@ -8,17 +10,26 @@ from tenant_schemas.utils import get_public_schema_name
class TenantMixin(models.Model):
auto_drop_schema = False # USE THIS WITH CAUTION!
# set this flag to true on a parent class if
# you want the schema to be automatically
# removed after tenant remove.
"""
All tenant models must inherit this class.
"""
auto_create_schema = True # set this flag to false on a parent class if
# you don't want the schema to be automatically
# created upon save.
auto_drop_schema = False
"""
USE THIS WITH CAUTION!
Set this flag to true on a parent class if you want the schema to be
automatically deleted if the tenant row gets deleted.
"""
auto_create_schema = True
"""
Set this flag to false on a parent class if you don't want the schema
to be automatically created upon save.
"""
domain_url = models.CharField(max_length=128, unique=True)
schema_name = models.CharField(max_length=63, unique=True, validators=[_check_schema_name])
schema_name = models.CharField(max_length=63, unique=True,
validators=[_check_schema_name])
class Meta:
abstract = True
@ -27,39 +38,47 @@ class TenantMixin(models.Model):
is_new = self.pk is None
if is_new and connection.schema_name != get_public_schema_name():
raise Exception("Can't create tenant outside the public schema. Current schema is %s."
% connection.schema_name)
raise Exception("Can't create tenant outside the public schema. "
"Current schema is %s." % connection.schema_name)
elif not is_new and connection.schema_name not in (self.schema_name, get_public_schema_name()):
raise Exception("Can't update tenant outside it's own schema or the public schema. Current schema is %s."
raise Exception("Can't update tenant outside it's own schema or "
"the public schema. Current schema is %s."
% connection.schema_name)
super(TenantMixin, self).save(*args, **kwargs)
if is_new and self.auto_create_schema:
self.create_schema(check_if_exists=True, verbosity=verbosity)
post_schema_sync.send(sender=TenantMixin, tenant=self)
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
def delete(self, *args, **kwargs):
def delete(self, force_drop=False, *args, **kwargs):
"""
Drops the schema related to the tenant instance. Just drop the schema if the parent
class model has the attribute auto_drop_schema set to True.
Deletes this row. Drops the tenant's schema if the attribute
auto_drop_schema set to True.
"""
if connection.schema_name not in (self.schema_name, get_public_schema_name()):
raise Exception("Can't delete tenant outside it's own schema or the public schema. Current schema is %s."
raise Exception("Can't delete tenant outside it's own schema or "
"the public schema. Current schema is %s."
% connection.schema_name)
if schema_exists(self.schema_name) and self.auto_drop_schema:
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)
transaction.commit_unless_managed()
super(TenantMixin, self).delete(*args, **kwargs)
def create_schema(self, check_if_exists=False, sync_schema=True, verbosity=1):
def create_schema(self, check_if_exists=False, sync_schema=True,
verbosity=1):
"""
Creates the schema 'schema_name' for this tenant. Optionally checks if the schema
already exists before creating it. Returns true if the schema was created, false
otherwise.
Creates the schema 'schema_name' for this tenant. Optionally checks if
the schema already exists before creating it. Returns true if the
schema was created, false otherwise.
"""
# safety check
@ -71,27 +90,29 @@ class TenantMixin(models.Model):
# create the schema
cursor.execute('CREATE SCHEMA %s' % self.schema_name)
transaction.commit_unless_managed()
if sync_schema:
# 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, # don't ask to create an admin user
migrate_all=fake_all_migrations,
verbosity=verbosity,
)
# run/fake all migrations
if 'south' in settings.INSTALLED_APPS and not django_is_in_test_mode():
if django.VERSION >= (1, 7, 0,):
call_command('migrate_schemas',
fake=fake_all_migrations,
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)
connection.set_schema_to_public()
return True

View File

@ -4,7 +4,9 @@ from django.conf import settings
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')
@ -43,6 +45,10 @@ class DatabaseWrapper(original_backend.DatabaseWrapper):
def __init__(self, *args, **kwargs):
super(DatabaseWrapper, self).__init__(*args, **kwargs)
# Use a patched version of the DatabaseIntrospection that only returns the table list for the
# currently selected schema.
self.introspection = DatabaseSchemaIntrospection(self)
self.set_schema_to_public()
def set_tenant(self, tenant, include_public=True):
@ -123,7 +129,7 @@ class DatabaseWrapper(original_backend.DatabaseWrapper):
# we do not have to worry that it's not the good one
try:
cursor.execute('SET search_path = {0}'.format(','.join(search_paths)))
except django.db.utils.DatabaseError:
except (django.db.utils.DatabaseError, psycopg2.InternalError):
self.search_path_set = False
else:
self.search_path_set = True

View File

@ -0,0 +1,14 @@
from django.db.backends.postgresql_psycopg2.introspection import DatabaseIntrospection
class DatabaseSchemaIntrospection(DatabaseIntrospection):
def get_table_list(self, cursor):
"Returns a list of table names in the current database and schema."
cursor.execute("""
SELECT c.relname
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)
return [row[0] for row in cursor.fetchall() if row[0] not in self.ignored_tables]

27
tenant_schemas/routers.py Normal file
View File

@ -0,0 +1,27 @@
from django.conf import settings
class TenantSyncRouter(object):
"""
A router to control which applications will be synced,
depending if we are syncing the shared apps or the tenant apps.
"""
def allow_migrate(self, db, model):
# the imports below need to be done here else django <1.5 goes crazy
# https://code.djangoproject.com/ticket/20704
from django.db import connection
from tenant_schemas.utils import get_public_schema_name, app_labels
if connection.schema_name == get_public_schema_name():
if model._meta.app_label not in app_labels(settings.SHARED_APPS):
return False
else:
if model._meta.app_label not in app_labels(settings.TENANT_APPS):
return False
return None
def allow_syncdb(self, db, model):
# allow_syncdb was changed to allow_migrate in django 1.7
return self.allow_migrate(db, model)

View File

@ -3,4 +3,4 @@ from django.dispatch import Signal
post_schema_sync = Signal(providing_args=['tenant'])
post_schema_sync.__doc__ = """
Sent after a tenant has been saved, its schema created and synced
"""
"""

View File

@ -7,7 +7,8 @@ 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.loader import (BaseLoader, get_template_from_string,
find_template_loader, make_origin)
from django.utils.encoding import force_bytes
from django.utils._os import safe_join
from django.db import connection
@ -37,7 +38,7 @@ class CachedLoader(BaseLoader):
for loader in self.loaders:
try:
template, display_name = loader(name, dirs)
return (template, make_origin(display_name, loader, name, dirs))
return template, make_origin(display_name, loader, name, dirs)
except TemplateDoesNotExist:
pass
raise TemplateDoesNotExist(name)
@ -48,7 +49,8 @@ class CachedLoader(BaseLoader):
else:
key = template_name
if template_dirs:
# If template directories were specified, use a hash to differentiate
# 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()])

View File

@ -16,4 +16,4 @@ class SchemaURLNode(URLNode):
@register.tag
def url(parser, token):
return SchemaURLNode(default_url(parser,token))
return SchemaURLNode(default_url(parser, token))

View File

@ -1,12 +1,16 @@
import django
from django.core.management import call_command
from django.db import connection
from django.test import TransactionTestCase
from django.test import TestCase
from tenant_schemas.utils import get_tenant_model
from tenant_schemas.utils import get_public_schema_name
class TenantTestCase(TransactionTestCase):
class TenantTestCase(TestCase):
@classmethod
def setUpClass(cls):
# create a tenant
cls.sync_shared()
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?
@ -15,9 +19,25 @@ class TenantTestCase(TransactionTestCase):
@classmethod
def tearDownClass(cls):
# delete tenant
connection.set_schema_to_public()
cls.tenant.delete()
cursor = connection.cursor()
cursor.execute('DROP SCHEMA test CASCADE')
cursor.execute('DROP SCHEMA test CASCADE')
@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,
)

View File

@ -24,16 +24,12 @@ class TenantRequestFactory(RequestFactory):
def patch(self, path, data={}, **extra):
if 'HTTP_HOST' not in extra:
extra['HTTP_HOST'] = self.tenant.domain_url
if 'content_type' not in extra:
extra["content_type"] = "application/json"
return super(TenantRequestFactory, self).patch(path, data, **extra)
def put(self, path, data={}, **extra):
if 'HTTP_HOST' not in extra:
extra['HTTP_HOST'] = self.tenant.domain_url
if 'content_type' not in extra:
extra["content_type"] = "application/json"
return super(TenantRequestFactory, self).put(path, data, **extra)
@ -67,16 +63,12 @@ class TenantClient(Client):
def patch(self, path, data={}, **extra):
if 'HTTP_HOST' not in extra:
extra['HTTP_HOST'] = self.tenant.domain_url
if 'content_type' not in extra:
extra["content_type"] = "application/json"
return super(TenantClient, self).patch(path, data, **extra)
def put(self, path, data={}, **extra):
if 'HTTP_HOST' not in extra:
extra['HTTP_HOST'] = self.tenant.domain_url
if 'content_type' not in extra:
extra["content_type"] = "application/json"
return super(TenantClient, self).put(path, data, **extra)
@ -85,4 +77,4 @@ class TenantClient(Client):
if 'HTTP_HOST' not in extra:
extra['HTTP_HOST'] = self.tenant.domain_url
return super(TenantClient, self).delete(path, data, **extra)
return super(TenantClient, self).delete(path, data, **extra)

View File

@ -1,3 +1,2 @@
from models import *
from routes import *
from tenants import *
from test_routes import *
from test_tenants import *

View File

@ -15,17 +15,3 @@ class NonAutoSyncTenant(TenantMixin):
class Meta:
app_label = 'tenant_schemas'
class DummyModel(models.Model):
"""
Just a test model so we can test manipulating data
inside a tenant
"""
name = models.CharField(max_length=1337) # every dummy should have a pretty name :)
def __unicode__(self):
return self.name
class Meta:
app_label = 'tenant_schemas'

View File

@ -1,103 +0,0 @@
from django.db import connection
from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant, DummyModel
from tenant_schemas.tests.testcases import BaseTestCase
from tenant_schemas.utils import tenant_context, schema_context, schema_exists
class TenantTestCase(BaseTestCase):
def tearDown(self):
super(TenantTestCase, self).tearDown()
NonAutoSyncTenant.objects.all().delete()
def test_tenant_schema_is_created(self):
"""
when saving a tenant, it's schema should be created
"""
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
self.assertTrue(schema_exists(tenant.schema_name))
def test_non_auto_sync_tenant(self):
"""
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'))
tenant = NonAutoSyncTenant(domain_url='something.test.com',
schema_name='test')
tenant.save()
self.assertFalse(schema_exists(tenant.schema_name))
def test_sync_tenant(self):
"""
when editing an existing tenant, all data should be kept
"""
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
# go to tenant's path
connection.set_tenant(tenant)
# add some data
DummyModel(name="Schemas are").save()
DummyModel(name="awesome!").save()
# edit tenant
connection.set_schema_to_public()
tenant.domain_url = 'example.com'
tenant.save()
connection.set_tenant(tenant)
# test if data is still there
self.assertEquals(DummyModel.objects.count(), 2)
def test_switching_search_path(self):
dummies_tenant1_count, dummies_tenant2_count = 0, 0
tenant1 = Tenant(domain_url='something.test.com',
schema_name='tenant1')
tenant1.save()
connection.set_schema_to_public()
tenant2 = Tenant(domain_url='example.com', schema_name='tenant2')
tenant2.save()
# go to tenant1's path
connection.set_tenant(tenant1)
# add some data
DummyModel(name="Schemas are").save()
DummyModel(name="awesome!").save()
dummies_tenant1_count = DummyModel.objects.count()
# switch temporarily to tenant2's path
with tenant_context(tenant2):
# add some data
DummyModel(name="Man,").save()
DummyModel(name="testing").save()
DummyModel(name="is great!").save()
dummies_tenant2_count = DummyModel.objects.count()
# we should be back to tenant1's path, test what we have
self.assertEqual(DummyModel.objects.count(), dummies_tenant1_count)
# switch back to tenant2's path
with tenant_context(tenant2):
self.assertEqual(DummyModel.objects.count(), dummies_tenant2_count)
def test_switching_tenant_without_previous_tenant(self):
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
connection.tenant = None
with tenant_context(tenant):
DummyModel(name="No exception please").save()
connection.tenant = None
with schema_context(tenant.schema_name):
DummyModel(name="Survived it!").save()

View File

@ -1,4 +1,6 @@
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
@ -6,6 +8,18 @@ from tenant_schemas.tests.testcases import BaseTestCase
class RoutesTestCase(BaseTestCase):
@classmethod
def setUpClass(cls):
super(RoutesTestCase, cls).setUpClass()
settings.SHARED_APPS = ('tenant_schemas', )
settings.TENANT_APPS = ('dts_test_app',
'django.contrib.contenttypes',
'django.contrib.auth', )
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
cls.sync_shared()
cls.public_tenant = Tenant(domain_url='test.com', schema_name=get_public_schema_name())
cls.public_tenant.save()
def setUp(self):
super(RoutesTestCase, self).setUp()
self.factory = RequestFactory()
@ -17,7 +31,7 @@ class RoutesTestCase(BaseTestCase):
def test_tenant_routing(self):
"""
request path should not be altered
Request path should not be altered.
"""
request_url = '/any/request/'
request = self.factory.get('/any/request/',
@ -31,11 +45,11 @@ class RoutesTestCase(BaseTestCase):
def test_public_schema_routing(self):
"""
request path should not be altered
Request path should not be altered.
"""
request_url = '/any/request/'
request = self.factory.get('/any/request/',
HTTP_HOST=self.public_tenant_domain)
HTTP_HOST=self.public_tenant.domain_url)
self.tm.process_request(request)
self.assertEquals(request.path_info, request_url)

View File

@ -0,0 +1,294 @@
import django
from django.conf import settings
from django.contrib.auth.models import User
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.testcases import BaseTestCase
from tenant_schemas.utils import tenant_context, schema_context, schema_exists, get_tenant_model, get_public_schema_name
class TenantDataAndSettingsTest(BaseTestCase):
"""
Tests if the tenant model settings work properly and if data can be saved
and persisted to different tenants.
"""
@classmethod
def setUpClass(cls):
super(TenantDataAndSettingsTest, cls).setUpClass()
settings.SHARED_APPS = ('tenant_schemas', )
settings.TENANT_APPS = ('dts_test_app',
'django.contrib.contenttypes',
'django.contrib.auth', )
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
cls.sync_shared()
Tenant(domain_url='test.com', schema_name=get_public_schema_name()).save()
def test_tenant_schema_is_created(self):
"""
When saving a tenant, it's schema should be created.
"""
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
self.assertTrue(schema_exists(tenant.schema_name))
def test_non_auto_sync_tenant(self):
"""
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'))
tenant = NonAutoSyncTenant(domain_url='something.test.com',
schema_name='test')
tenant.save()
self.assertFalse(schema_exists(tenant.schema_name))
def test_sync_tenant(self):
"""
When editing an existing tenant, all data should be kept.
"""
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
# go to tenant's path
connection.set_tenant(tenant)
# add some data
DummyModel(name="Schemas are").save()
DummyModel(name="awesome!").save()
# edit tenant
connection.set_schema_to_public()
tenant.domain_url = 'example.com'
tenant.save()
connection.set_tenant(tenant)
# test if data is still there
self.assertEquals(DummyModel.objects.count(), 2)
def test_switching_search_path(self):
tenant1 = Tenant(domain_url='something.test.com',
schema_name='tenant1')
tenant1.save()
connection.set_schema_to_public()
tenant2 = Tenant(domain_url='example.com', schema_name='tenant2')
tenant2.save()
# go to tenant1's path
connection.set_tenant(tenant1)
# add some data, 2 DummyModels for tenant1
DummyModel(name="Schemas are").save()
DummyModel(name="awesome!").save()
# switch temporarily to tenant2's path
with tenant_context(tenant2):
# add some data, 3 DummyModels for tenant2
DummyModel(name="Man,").save()
DummyModel(name="testing").save()
DummyModel(name="is great!").save()
# we should be back to tenant1's path, test what we have
self.assertEqual(2, DummyModel.objects.count())
# switch back to tenant2's path
with tenant_context(tenant2):
self.assertEqual(3, DummyModel.objects.count())
def test_switching_tenant_without_previous_tenant(self):
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
connection.tenant = None
with tenant_context(tenant):
DummyModel(name="No exception please").save()
connection.tenant = None
with schema_context(tenant.schema_name):
DummyModel(name="Survived it!").save()
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
def test_shared_apps_does_not_sync_tenant_apps(self):
"""
Tests that if an app is in SHARED_APPS, it does not get synced to
the a tenant schema.
"""
settings.SHARED_APPS = ('tenant_schemas', # 2 tables
'django.contrib.auth', # 6 tables
'django.contrib.contenttypes', ) # 1 table
settings.TENANT_APPS = ('django.contrib.sessions', )
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
self.sync_shared()
shared_tables = self.get_tables_list_in_schema(get_public_schema_name())
self.assertEqual(2+6+1+self.MIGRATION_TABLE_SIZE, len(shared_tables))
self.assertNotIn('django_session', shared_tables)
def test_tenant_apps_does_not_sync_shared_apps(self):
"""
Tests that if an app is in TENANT_APPS, it does not get synced to
the public schema.
"""
settings.SHARED_APPS = ('tenant_schemas',
'django.contrib.auth',
'django.contrib.contenttypes', )
settings.TENANT_APPS = ('django.contrib.sessions', ) # 1 table
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
self.sync_shared()
tenant = Tenant(domain_url='arbitrary.test.com', schema_name='test')
tenant.save()
tenant_tables = self.get_tables_list_in_schema(tenant.schema_name)
self.assertEqual(1+self.MIGRATION_TABLE_SIZE, len(tenant_tables))
self.assertIn('django_session', tenant_tables)
def test_tenant_apps_and_shared_apps_can_have_the_same_apps(self):
"""
Tests that both SHARED_APPS and TENANT_APPS can have apps in common.
In this case they should get synced to both tenant and public schemas.
"""
settings.SHARED_APPS = ('tenant_schemas', # 2 tables
'django.contrib.auth', # 6 tables
'django.contrib.contenttypes', # 1 table
'django.contrib.sessions', ) # 1 table
settings.TENANT_APPS = ('django.contrib.sessions', ) # 1 table
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
self.sync_shared()
tenant = Tenant(domain_url='arbitrary.test.com', schema_name='test')
tenant.save()
shared_tables = self.get_tables_list_in_schema(get_public_schema_name())
tenant_tables = self.get_tables_list_in_schema(tenant.schema_name)
self.assertEqual(2+6+1+1+self.MIGRATION_TABLE_SIZE, len(shared_tables))
self.assertIn('django_session', shared_tables)
self.assertEqual(1+self.MIGRATION_TABLE_SIZE, len(tenant_tables))
self.assertIn('django_session', tenant_tables)
def test_content_types_is_not_mandatory(self):
"""
Tests that even if content types is in SHARED_APPS, it's
not required in TENANT_APPS.
"""
settings.SHARED_APPS = ('tenant_schemas', # 2 tables
'django.contrib.contenttypes', ) # 1 table
settings.TENANT_APPS = ('django.contrib.sessions', ) # 1 table
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
self.sync_shared()
tenant = Tenant(domain_url='something.test.com', schema_name='test')
tenant.save()
shared_tables = self.get_tables_list_in_schema(get_public_schema_name())
tenant_tables = self.get_tables_list_in_schema(tenant.schema_name)
self.assertEqual(2+1+self.MIGRATION_TABLE_SIZE, len(shared_tables))
self.assertIn('django_session', tenant_tables)
self.assertEqual(1+self.MIGRATION_TABLE_SIZE, len(tenant_tables))
self.assertIn('django_session', tenant_tables)
class SharedAuthTest(BaseTestCase):
@classmethod
def setUpClass(cls):
super(SharedAuthTest, cls).setUpClass()
settings.SHARED_APPS = ('tenant_schemas',
'django.contrib.auth',
'django.contrib.contenttypes', )
settings.TENANT_APPS = ('dts_test_app', )
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
cls.sync_shared()
Tenant(domain_url='test.com', schema_name=get_public_schema_name()).save()
# Create a tenant
cls.tenant = Tenant(domain_url='tenant.test.com', schema_name='tenant')
cls.tenant.save()
# Create some users
with schema_context(get_public_schema_name()): # this could actually also be executed inside a tenant
cls.user1 = User(username='arbitrary-1', email="arb1@test.com")
cls.user1.save()
cls.user2 = User(username='arbitrary-2', email="arb2@test.com")
cls.user2.save()
# Create instances on the tenant that point to the users on public
with tenant_context(cls.tenant):
cls.d1 = ModelWithFkToPublicUser(user=cls.user1)
cls.d1.save()
cls.d2 = ModelWithFkToPublicUser(user=cls.user2)
cls.d2.save()
def test_cross_schema_constraint_gets_created(self):
"""
Tests that a foreign key constraint gets created even for cross schema references.
"""
sql = """
SELECT
tc.constraint_name, tc.table_name, kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM
information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name=%s
"""
cursor = connection.cursor()
cursor.execute(sql, (ModelWithFkToPublicUser._meta.db_table, ))
fk_constraints = cursor.fetchall()
self.assertEqual(1, len(fk_constraints))
# The foreign key should reference the primary key of the user table
fk = fk_constraints[0]
self.assertEqual(User._meta.db_table, fk[3])
self.assertEqual('id', fk[4])
def test_direct_relation_to_public(self):
"""
Tests that a forward relationship through a foreign key to public from a model inside TENANT_APPS works.
"""
with tenant_context(self.tenant):
self.assertEqual(User.objects.get(pk=self.user1.id),
ModelWithFkToPublicUser.objects.get(pk=self.d1.id).user)
self.assertEqual(User.objects.get(pk=self.user2.id),
ModelWithFkToPublicUser.objects.get(pk=self.d2.id).user)
def test_reverse_relation_to_public(self):
"""
Tests that a reverse relationship through a foreign keys to public from a model inside TENANT_APPS works.
"""
with tenant_context(self.tenant):
users = User.objects.all().select_related().order_by('id')
self.assertEqual(ModelWithFkToPublicUser.objects.get(pk=self.d1.id),
users[0].modelwithfktopublicuser_set.all()[:1].get())
self.assertEqual(ModelWithFkToPublicUser.objects.get(pk=self.d2.id),
users[1].modelwithfktopublicuser_set.all()[:1].get())
class TenantTestCaseTest(BaseTestCase, TenantTestCase):
"""
Tests that the tenant created inside TenantTestCase persists on
all functions.
"""
def test_tenant_survives_after_method1(self):
# There is one tenant in the database, the one created by TenantTestCase
self.assertEquals(1, get_tenant_model().objects.all().count())
def test_tenant_survives_after_method2(self):
# The same tenant still exists even after the previous method call
self.assertEquals(1, get_tenant_model().objects.all().count())

View File

@ -1,49 +1,59 @@
import django
from django.conf import settings
from django.core.management import call_command
from django.db import connection
from django.test import TransactionTestCase
from django.test import TestCase
from .models import Tenant
from ..utils import get_public_schema_name
from tenant_schemas.utils import get_public_schema_name
class BaseTestCase(TransactionTestCase):
""" Base test case that comes packed with overloaded INSTALLED_APPS,
custom public tenant, and schemas cleanup on tearDown.
class BaseTestCase(TestCase):
"""
Base test case that comes packed with overloaded INSTALLED_APPS,
custom public tenant, and schemas cleanup on tearDown.
"""
@classmethod
def setUpClass(cls):
settings.TENANT_APPS = ('tenant_schemas',
settings.TENANT_MODEL = 'tenant_schemas.Tenant'
settings.SHARED_APPS = ('tenant_schemas', )
settings.TENANT_APPS = ('dts_test_app',
'django.contrib.contenttypes',
'django.contrib.auth', )
settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS
# Django calls syncdb by default for the test database, but we want
# 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;'
% (get_public_schema_name(), get_public_schema_name(), ))
super(BaseTestCase, cls).setUpClass()
def setUp(self):
# settings needs some patching
settings.TENANT_MODEL = 'tenant_schemas.Tenant'
# add the public tenant
self.public_tenant_domain = 'test.com'
self.public_tenant = Tenant(domain_url=self.public_tenant_domain,
schema_name='public')
self.public_tenant.save()
connection.set_schema_to_public()
super(BaseTestCase, self).setUp()
def tearDown(self):
"""
Delete all tenant schemas. Tenant schema are not deleted
automatically by django.
"""
connection.set_schema_to_public()
do_not_delete = [get_public_schema_name(), 'information_schema']
@classmethod
def get_tables_list_in_schema(cls, schema_name):
cursor = connection.cursor()
sql = """SELECT table_name FROM information_schema.tables
WHERE table_schema = %s"""
cursor.execute(sql, (schema_name, ))
return [row[0] for row in cursor.fetchall()]
# Use information_schema.schemata instead of pg_catalog.pg_namespace in
# utils.schema_exists, so that we only "see" schemas that we own
cursor.execute('SELECT schema_name FROM information_schema.schemata')
for row in cursor.fetchall():
if not row[0].startswith('pg_') and row[0] not in do_not_delete:
print("Deleting schema %s" % row[0])
cursor.execute('DROP SCHEMA %s CASCADE' % row[0])
Tenant.objects.all().delete()
@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,
)

View File

@ -55,11 +55,12 @@ def clean_tenant_url(url_string):
def remove_www_and_dev(hostname):
"""
"""
Legacy function - just in case someone is still using the old name
"""
return remove_www(hostname)
def remove_www(hostname):
"""
Removes www. from the beginning of the address. Only for
@ -96,3 +97,10 @@ def schema_exists(schema_name):
cursor.close()
return exists
def app_labels(apps_list):
"""
Returns a list of app labels of the given apps_list
"""
return [app.split('.')[-1] for app in apps_list]