Merge remote-tracking branch 'github/master'
Conflicts: tenant_schemas/postgresql_backend/base.py
This commit is contained in:
commit
c523006c61
|
@ -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
|
||||
|
|
14
README.rst
14
README.rst
|
@ -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/
|
||||
|
|
|
@ -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)
|
||||
=========================================
|
||||
|
|
|
@ -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>`. :)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
28
docs/use.rst
28
docs/use.rst
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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/'
|
|
@ -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)
|
|
@ -2,4 +2,4 @@ from django import forms
|
|||
|
||||
|
||||
class GenerateUsersForm(forms.Form):
|
||||
pass
|
||||
pass
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
1
setup.py
1
setup.py
|
@ -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',
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -3,4 +3,4 @@ from django.contrib.auth.management.commands import createsuperuser
|
|||
|
||||
|
||||
class Command(TenantWrappedCommand):
|
||||
COMMAND = createsuperuser.Command
|
||||
COMMAND = createsuperuser.Command
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Commands for Django <=1.6 (before south was integrated to Django).
|
||||
"""
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
|
@ -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)
|
|
@ -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
|
||||
"""
|
||||
"""
|
||||
|
|
|
@ -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()])
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
from models import *
|
||||
from routes import *
|
||||
from tenants import *
|
||||
from test_routes import *
|
||||
from test_tenants import *
|
||||
|
|
|
@ -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'
|
|
@ -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()
|
|
@ -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)
|
|
@ -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())
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue