Merge branch 'master' of github.com:bernardopires/django-tenant-schemas

This commit is contained in:
Bernardo Pires 2016-05-30 00:08:28 +02:00
commit f1e322ae6c
28 changed files with 509 additions and 473 deletions

View File

@ -160,8 +160,7 @@ created ``Client`` inside an app named ``customers``, your
TENANT_MODEL = "customers.Client" # app.Model
Now run ``migrate_schemas`` (``sync_schemas`` if you're on Django 1.6 and older),
this will sync your apps to the ``public`` schema.
Now run ``migrate_schemas`` to sync your apps to the ``public`` schema.
::

View File

@ -73,7 +73,6 @@ Once you have defined your model, don't forget to create the migrations for it o
.. code-block:: bash
# Django >= 1.7
python manage.py makemigrations customers
Configure Tenant and Shared Applications
@ -113,20 +112,16 @@ You also have to set where your tenant model is.
TENANT_MODEL = "customers.Client" # app.Model
Now run ``migrate_schemas --shared`` (``sync_schemas --shared`` if you're on Django 1.6 or older), this will create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command.
Now run ``migrate_schemas --shared`` to create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command.
.. code-block:: bash
# Django >= 1.7
python manage.py migrate_schemas --shared
# Django < 1.7
python manage.py sync_schemas --shared
.. warning::
Never use ``migrate`` or ``syncdb`` as it would sync *all* your apps to ``public``!
Never use ``migrate`` as it would sync *all* your apps to ``public``!
Lastly, you need to create a tenant whose schema is ``public`` and it's address is your domain URL. Please see the section on :doc:`use <use>`.
You can also specify extra schemas that should be visible to all queries using
@ -145,34 +140,6 @@ globally.
available globally. This helps avoid issues caused by hiding the public
schema from queries.
South Migrations
================
If you're on Django 1.6 or older, this app supports `South <http://south.aeracode.org/>`_ so if you haven't configured it yet and would like to:
For Django 1.1 or below
.. code-block:: python
SOUTH_DATABASE_ADAPTER = 'south.db.postgresql_psycopg2'
For Django 1.2 or above
.. code-block:: python
SOUTH_DATABASE_ADAPTERS = {
'default': 'south.db.postgresql_psycopg2',
}
You can list ``south`` under ``TENANT_APPS`` and ``SHARED_APPS`` if you want.
We override ``south``'s ``syncdb`` and ``migrate`` command, so you'll need to change your ``INSTALLED_APPS`` to
.. code-block:: python
INSTALLED_APPS = SHARED_APPS + TENANT_APPS + ('tenant_schemas',)
This makes sure ``tenant_schemas`` is the last on the list and therefore always has precedence when running an overridden command.
Optional Settings
=================

View File

@ -66,12 +66,12 @@ To run only a particular schema, there is an optional argument called ``--schema
.. code-block:: bash
./manage.py sync_schemas --schema=customer1
./manage.py migrate_schemas --schema=customer1
migrate_schemas
~~~~~~~~~~~~~~~
If you're on Django 1.7 or newer, ``migrate_schemas`` is the most important command on this app. The way it works is that it calls Django's ``migrate`` in two different ways. First, it calls ``migrate`` for the ``public`` schema, only syncing the shared apps. Then it runs ``migrate`` for every tenant in the database, this time only syncing the tenant apps.
``migrate_schemas`` is the most important command on this app. The way it works is that it calls Django's ``migrate`` in two different ways. First, it calls ``migrate`` for the ``public`` schema, only syncing the shared apps. Then it runs ``migrate`` for every tenant in the database, this time only syncing the tenant apps.
.. warning::
@ -87,27 +87,6 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H
./manage.py migrate_schemas --list
sync_schemas
~~~~~~~~~~~~
If you're on Django 1.6 or older, we also packed ``sync_schemas``. It will also respect the ``SHARED_APPS`` and ``TENANT_APPS`` settings, so if you're syncing the ``public`` schema it will only sync ``SHARED_APPS``. If you're syncing tenants, it will only migrate ``TENANT_APPS``.
.. warning::
You should never directly call ``syncdb``. We perform some magic in order to make ``syncdb`` only sync the appropriate apps.
The options given to ``sync_schemas`` are passed to every ``syncdb``. So if you use South, you may find this handy
.. code-block:: bash
./manage.py sync_schemas --migrate
You can also use the option ``--tenant`` to only sync tenant apps or ``--shared`` to only sync shared apps.
.. code-block:: bash
./manage.py sync_schemas --shared # will only sync the public schema
tenant_command
~~~~~~~~~~~~~~

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dts_test_app', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='DummyModel',
name='unique_value',
field=models.IntegerField(blank=True, null=True, unique=True),
),
migrations.AlterField(
model_name='DummyModel',
name='unique_value',
field=models.IntegerField(blank=True, null=True),
),
migrations.RemoveField(
model_name='DummyModel',
name='unique_value',
),
]

View File

@ -2,10 +2,10 @@
Django settings for dts_test_project project.
For more information on this file, see
https://docs.djangoproject.com/en/1.6/topics/settings/
https://docs.djangoproject.com/en/1.8/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.6/ref/settings/
https://docs.djangoproject.com/en/1.8/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -14,7 +14,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/
# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'cl1)b#c&xmm36z3e(quna-vb@ab#&gpjtdjtpyzh!qn%bc^xxn'
@ -48,11 +48,7 @@ TENANT_MODEL = "customers.Client" # app.Model
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
import django
if django.VERSION >= (1, 7, 0):
INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS))
else:
INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',)
INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS))
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
@ -69,7 +65,7 @@ WSGI_APPLICATION = 'dts_test_project.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.6/ref/settings/#databases
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
DATABASES = {
'default': {
@ -104,7 +100,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
)
# Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us'
@ -118,7 +114,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.6/howto/static-files/
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_URL = '/static/'

View File

@ -18,11 +18,7 @@
</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>

View File

@ -143,11 +143,7 @@ TENANT_APPS = (
TENANT_MODEL = "customers.Client" # app.Model
import django
if django.VERSION >= (1, 7, 0):
INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS))
else:
INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',)
INSTALLED_APPS = list(set(TENANT_APPS + SHARED_APPS))
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer'

View File

@ -1,4 +1,3 @@
import django
from django.conf.urls import url
from tenant_tutorial.views import HomeView
@ -6,6 +5,3 @@ from tenant_tutorial.views import HomeView
urlpatterns = [
url(r'^$', HomeView.as_view()),
]
if django.VERSION < (1, 9, 0):
urlpatterns = django.conf.urls.patterns('', *urlpatterns)

View File

@ -1,11 +1,6 @@
import django
from django.conf.urls import url
from customers.views import TenantView
from django.conf.urls import url
urlpatterns = [
url(r'^$', TenantView.as_view()),
]
if django.VERSION < (1, 9, 0):
urlpatterns = django.conf.urls.patterns('', *urlpatterns)

View File

@ -1,9 +1,9 @@
import django
from customers.models import Client
from django.conf import settings
from django.db import utils
from django.views.generic import TemplateView
from tenant_schemas.utils import remove_www
from customers.models import Client
class HomeView(TemplateView):
@ -14,9 +14,6 @@ class HomeView(TemplateView):
hostname_without_port = remove_www(self.request.get_host().split(':')[0])
if django.VERSION >= (1, 7, 0):
context['DJANGO17'] = True
try:
Client.objects.get(schema_name='public')
except utils.DatabaseError:

View File

@ -8,8 +8,6 @@ try:
except ImportError:
from distutils.core import setup
__version__ = "1.4.0"
setup(
name='django-tenant-schemas',
version=get_git_version(),
@ -20,7 +18,6 @@ setup(
'tenant_schemas.postgresql_backend',
'tenant_schemas.management',
'tenant_schemas.management.commands',
'tenant_schemas.management.commands.legacy',
'tenant_schemas.templatetags',
'tenant_schemas.test',
'tenant_schemas.tests',
@ -36,7 +33,7 @@ setup(
'Programming Language :: Python',
],
install_requires=[
'Django >= 1.6.0',
'Django >= 1.8.0',
'psycopg2',
],
zip_safe=False,

View File

@ -1,9 +1,7 @@
import django
import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from tenant_schemas.utils import get_public_schema_name, get_tenant_model
from tenant_schemas.utils import get_public_schema_name, get_tenant_model
recommended_config = """
Warning: You should put 'tenant_schemas' at the end of INSTALLED_APPS:
@ -23,9 +21,6 @@ if not settings.TENANT_APPS:
if not hasattr(settings, 'TENANT_MODEL'):
raise ImproperlyConfigured('TENANT_MODEL setting not set')
if django.VERSION < (1, 7, 0) and settings.INSTALLED_APPS[-1] != 'tenant_schemas':
warnings.warn(recommended_config, SyntaxWarning)
if 'tenant_schemas.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS:
raise ImproperlyConfigured("DATABASE_ROUTERS setting must contain "
"'tenant_schemas.routers.TenantSyncRouter'.")

View File

@ -1,9 +1,7 @@
import django
from optparse import make_option
from django.conf import settings
from django.core.management import call_command, get_commands, load_command_class
from django.core.management.base import BaseCommand, NoArgsCommand, CommandError
from django.db import connection, DEFAULT_DB_ALIAS
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
try:
from django.utils.six.moves import input
@ -32,16 +30,6 @@ class BaseTenantCommand(BaseCommand):
else:
cmdclass = load_command_class(app_name, obj.COMMAND_NAME)
if django.VERSION < (1, 8, 0):
# inherit the options from the original command
obj.option_list = cmdclass.option_list
obj.option_list += (
make_option("-s", "--schema", dest="schema_name"),
)
obj.option_list += (
make_option("-p", "--skip-public", dest="skip_public", action="store_true", default=False),
)
# prepend the command's original help with the info about schemata iteration
obj.help = "Calls %s for all registered schemata. You can use regular %s options. " \
"Original help for %s: %s" % (obj.COMMAND_NAME, obj.COMMAND_NAME, obj.COMMAND_NAME,
@ -83,12 +71,6 @@ class BaseTenantCommand(BaseCommand):
class InteractiveTenantOption(object):
def __init__(self, *args, **kwargs):
super(InteractiveTenantOption, self).__init__(*args, **kwargs)
if django.VERSION < (1, 8, 0):
self.option_list += (
make_option("-s", "--schema", dest="schema_name", help="specify tenant schema"),
)
def add_arguments(self, parser):
parser.add_argument("-s", "--schema", dest="schema_name", help="specify tenant schema")
@ -143,28 +125,8 @@ class TenantWrappedCommand(InteractiveTenantOption, BaseCommand):
class SyncCommon(BaseCommand):
if django.VERSION < (1, 8, 0):
option_list = (
make_option('--tenant', action='store_true', dest='tenant', default=False,
help='Tells Django to populate only tenant applications.'),
make_option('--shared', action='store_true', dest='shared', default=False,
help='Tells Django to populate only shared applications.'),
make_option('--app_label', action='store', dest='app_label', nargs='?',
help='App label of an application to synchronize the state.'),
make_option('--migration_name', action='store', dest='migration_name', nargs='?',
help=('Database state will be brought to the state after that '
'migration. Use the name "zero" to unapply all migrations.')),
make_option("-s", "--schema", dest="schema_name"),
)
def __init__(self, stdout=None, stderr=None, no_color=False):
if django.VERSION >= (1, 8, 0):
super(SyncCommon, self).__init__(stdout, stderr, no_color)
else:
super(SyncCommon, self).__init__()
def add_arguments(self, parser):
# for django 1.8 and above
parser.add_argument('--tenant', action='store_true', dest='tenant', default=False,
help='Tells Django to populate only tenant applications.')
parser.add_argument('--shared', action='store_true', dest='shared', default=False,

View File

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

View File

@ -1,88 +0,0 @@
from django.conf import settings
from django.db import connection
from south import migration
from south.migration.base import Migrations
from south.management.commands.migrate import Command as MigrateCommand
from tenant_schemas.management.commands import SyncCommon
from tenant_schemas.utils import get_tenant_model, get_public_schema_name
class Command(SyncCommon):
help = "Migrate schemas with South"
option_list = MigrateCommand.option_list + SyncCommon.option_list
def handle(self, *args, **options):
super(Command, self).handle(*args, **options)
if self.sync_public:
self.migrate_public_apps()
if self.sync_tenant:
self.migrate_tenant_apps(self.schema_name)
def _set_managed_apps(self, included_apps, excluded_apps):
""" while sync_schemas works by setting which apps are managed, on south we set which apps should be ignored """
ignored_apps = []
if excluded_apps:
for item in excluded_apps:
if item not in included_apps:
ignored_apps.append(item)
for app in ignored_apps:
app_label = app.split('.')[-1]
settings.SOUTH_MIGRATION_MODULES[app_label] = 'ignore'
self._clear_south_cache()
def _save_south_settings(self):
self._old_south_modules = None
if hasattr(settings, "SOUTH_MIGRATION_MODULES") and settings.SOUTH_MIGRATION_MODULES is not None:
self._old_south_modules = settings.SOUTH_MIGRATION_MODULES.copy()
else:
settings.SOUTH_MIGRATION_MODULES = dict()
def _restore_south_settings(self):
settings.SOUTH_MIGRATION_MODULES = self._old_south_modules
def _clear_south_cache(self):
for mig in list(migration.all_migrations()):
delattr(mig._application, "migrations")
Migrations._clear_cache()
def _migrate_schema(self, tenant):
connection.set_tenant(tenant, include_public=True)
MigrateCommand().execute(*self.args, **self.options)
def migrate_tenant_apps(self, schema_name=None):
self._save_south_settings()
apps = self.tenant_apps or self.installed_apps
self._set_managed_apps(included_apps=apps, excluded_apps=self.shared_apps)
if schema_name:
self._notice("=== Running migrate for schema: %s" % schema_name)
connection.set_schema_to_public()
tenant = get_tenant_model().objects.get(schema_name=schema_name)
self._migrate_schema(tenant)
else:
all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name())
if not all_tenants:
self._notice("No tenants found")
for tenant in all_tenants:
Migrations._dependencies_done = False # very important, the dependencies need to be purged from cache
self._notice("=== Running migrate for schema %s" % tenant.schema_name)
self._migrate_schema(tenant)
self._restore_south_settings()
def migrate_public_apps(self):
self._save_south_settings()
apps = self.shared_apps or self.installed_apps
self._set_managed_apps(included_apps=apps, excluded_apps=self.tenant_apps)
self._notice("=== Running migrate for schema public")
MigrateCommand().execute(*self.args, **self.options)
self._clear_south_cache()
self._restore_south_settings()

View File

@ -1,6 +1,7 @@
import django
from django.conf import settings
from django.core.management.base import CommandError, BaseCommand
from tenant_schemas.management.commands.migrate_schemas import Command as MigrateSchemasCommand
from tenant_schemas.utils import django_is_in_test_mode
try:
@ -21,6 +22,5 @@ class Command(MigrateCommand):
super(Command, self).handle(*args, **options)
if django.VERSION >= (1, 7, 0) and django_is_in_test_mode():
from .migrate_schemas import MigrateSchemasCommand
if django_is_in_test_mode():
Command = MigrateSchemasCommand

View File

@ -1,17 +1,12 @@
import django
from optparse import NO_DEFAULT
if django.VERSION >= (1, 7, 0):
from django.core.management.commands.migrate import Command as MigrateCommand
from django.db.migrations.recorder import MigrationRecorder
from django.db import connection
from django.conf import settings
from django.core.management.commands.migrate import Command as MigrateCommand
from django.db import connection
from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists
from tenant_schemas.management.commands import SyncCommon
from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists
class MigrateSchemasCommand(SyncCommon):
class Command(SyncCommon):
help = "Updates database schema. Manages both apps with migrations and those without."
def __init__(self, stdout=None, stderr=None, no_color=False):
@ -19,18 +14,15 @@ class MigrateSchemasCommand(SyncCommon):
Changes the option_list to use the options from the wrapped migrate command.
"""
self.option_list += MigrateCommand.option_list
if django.VERSION >= (1, 8, 0):
super(MigrateSchemasCommand, self).__init__(stdout, stderr, no_color)
else:
super(MigrateSchemasCommand, self).__init__()
super(Command, self).__init__(stdout, stderr, no_color)
def add_arguments(self, parser):
super(MigrateSchemasCommand, self).add_arguments(parser)
super(Command, self).add_arguments(parser)
command = MigrateCommand()
command.add_arguments(parser)
def handle(self, *args, **options):
super(MigrateSchemasCommand, self).handle(*args, **options)
super(Command, self).handle(*args, **options)
self.PUBLIC_SCHEMA_NAME = get_public_schema_name()
if self.sync_public and not self.schema_name:
@ -60,9 +52,3 @@ class MigrateSchemasCommand(SyncCommon):
def _notice(self, output):
self.stdout.write(self.style.NOTICE(output))
if django.VERSION >= (1, 7, 0):
Command = MigrateSchemasCommand
else:
from .legacy.migrate_schemas import Command

View File

@ -1,57 +0,0 @@
import django
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured
from django.db.models import get_apps, get_models
if "south" in settings.INSTALLED_APPS:
from south.management.commands.syncdb import Command as SyncdbCommand
else:
from django.core.management.commands.syncdb import Command as SyncdbCommand
from django.db import connection
from tenant_schemas.utils import get_tenant_model, get_public_schema_name
from tenant_schemas.management.commands import SyncCommon
class Command(SyncCommon):
help = "Sync schemas based on TENANT_APPS and SHARED_APPS settings"
option_list = SyncdbCommand.option_list + SyncCommon.option_list
def handle(self, *args, **options):
if django.VERSION >= (1, 7, 0):
raise RuntimeError('This command is only meant to be used for 1.6'
' and older version of django. For 1.7, use'
' `migrate_schemas` instead.')
super(Command, self).handle(*args, **options)
if "south" in settings.INSTALLED_APPS:
self.options["migrate"] = False
# Content types may be different on tenants, so reset the cache
ContentType.objects.clear_cache()
if self.sync_public:
self.sync_public_apps()
if self.sync_tenant:
self.sync_tenant_apps(self.schema_name)
def _sync_tenant(self, tenant):
if int(self.options.get('verbosity', 1)) >= 1:
self._notice("=== Running syncdb for schema: %s" % tenant.schema_name)
connection.set_tenant(tenant, include_public=False)
SyncdbCommand().execute(**self.options)
def sync_tenant_apps(self, schema_name=None):
if schema_name:
tenant = get_tenant_model().objects.filter(schema_name=schema_name).get()
self._sync_tenant(tenant)
else:
all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name())
if not all_tenants:
self._notice("No tenants found!")
for tenant in all_tenants:
self._sync_tenant(tenant)
def sync_public_apps(self):
SyncdbCommand().execute(**self.options)
self._notice("=== Running syncdb for schema public")

View File

@ -1,21 +0,0 @@
from django.core.management.base import CommandError
from django.conf import settings
from tenant_schemas.utils import django_is_in_test_mode
if 'south' in settings.INSTALLED_APPS:
from south.management.commands import syncdb
else:
from django.core.management.commands import syncdb
class Command(syncdb.Command):
def handle(self, *args, **options):
database = options.get('database', 'default')
if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' and not
django_is_in_test_mode()):
raise CommandError("syncdb has been disabled, for database '{0}'. "
"Use sync_schemas instead. Please read the "
"documentation if you don't know why "
"you shouldn't call syncdb directly!".format(database))
super(Command, self).handle(*args, **options)

View File

@ -50,12 +50,13 @@ class TenantMixin(models.Model):
if is_new and self.auto_create_schema:
try:
self.create_schema(check_if_exists=True, verbosity=verbosity)
post_schema_sync.send(sender=TenantMixin, tenant=self)
except:
# We failed creating the tenant, delete what we created and
# re-raise the exception
self.delete(force_drop=True)
raise
else:
post_schema_sync.send(sender=TenantMixin, tenant=self)
def delete(self, force_drop=False, *args, **kwargs):
"""
@ -92,27 +93,9 @@ class TenantMixin(models.Model):
cursor.execute('CREATE SCHEMA %s' % self.schema_name)
if sync_schema:
if django.VERSION >= (1, 7, 0,):
call_command('migrate_schemas',
schema_name=self.schema_name,
interactive=False,
verbosity=verbosity)
else:
# default is faking all migrations and syncing directly to the current models state
fake_all_migrations = getattr(settings, 'TENANT_CREATION_FAKES_MIGRATIONS', True)
call_command('sync_schemas',
schema_name=self.schema_name,
tenant=True,
public=False,
interactive=False,
migrate_all=fake_all_migrations,
verbosity=verbosity)
# run/fake all migrations
if 'south' in settings.INSTALLED_APPS and not django_is_in_test_mode():
call_command('migrate_schemas',
fake=fake_all_migrations,
schema_name=self.schema_name,
verbosity=verbosity)
call_command('migrate_schemas',
schema_name=self.schema_name,
interactive=False,
verbosity=verbosity)
connection.set_schema_to_public()

View File

@ -1,22 +1,302 @@
import django
from django.db.backends.postgresql_psycopg2.introspection import DatabaseIntrospection
if django.VERSION >= (1, 8, 0):
from django.db.backends.base.introspection import TableInfo
from django.db.backends.base.introspection import TableInfo
from django.db.backends.postgresql_psycopg2.introspection import (
DatabaseIntrospection,
FieldInfo,
)
from django.utils.encoding import force_text
def _build_table_info(row):
return TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1]))
def _build_field_info(col, field_map):
col_name = force_text(col[0])
info_args = [col_name]
info_args.extend(col[1:6])
# is nullable
info_args.append(field_map[col_name][0] == 'YES')
# default value
info_args.append(field_map[col_name][1])
return FieldInfo(*info_args)
class DatabaseSchemaIntrospection(DatabaseIntrospection):
_get_table_list_query = """
SELECT c.relname, c.relkind
FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'v', '')
AND n.nspname = %(schema)s
AND pg_catalog.pg_table_is_visible(c.oid)
"""
_get_table_description_query = """
SELECT column_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = %(table)s
AND table_schema = %(schema)s
"""
_get_relations_query = """
SELECT c2.relname, a1.attname, a2.attname, con.conkey, con.confkey
FROM pg_catalog.pg_constraint con
LEFT JOIN pg_catalog.pg_class c1 ON con.conrelid = c1.oid
LEFT JOIN pg_catalog.pg_class c2 ON con.confrelid = c2.oid
LEFT JOIN pg_catalog.pg_attribute a1 ON c1.oid = a1.attrelid
AND a1.attnum = con.conkey[1]
LEFT JOIN pg_catalog.pg_attribute a2 ON c2.oid = a2.attrelid
AND a2.attnum = con.confkey[1]
LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = c1.connamespace
WHERE c1.relname = %(table)s
AND n1.nspname = %(schema)s
AND con.contype = 'f'
"""
_get_key_columns_query = """
SELECT kcu.column_name, ccu.table_name AS referenced_table,
ccu.column_name AS referenced_column
FROM information_schema.constraint_column_usage ccu
LEFT JOIN information_schema.key_column_usage kcu
ON ccu.constraint_catalog = kcu.constraint_catalog
AND ccu.constraint_schema = kcu.constraint_schema
AND ccu.constraint_name = kcu.constraint_name
LEFT JOIN information_schema.table_constraints tc
ON ccu.constraint_catalog = tc.constraint_catalog
AND ccu.constraint_schema = tc.constraint_schema
AND ccu.constraint_name = tc.constraint_name
WHERE kcu.table_name = %(table)s
AND kcu.table_schame = %(schema)s
AND tc.constraint_type = 'FOREIGN KEY'
"""
_get_indexes_query = """
SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary
FROM pg_catalog.pg_class c, pg_catalog.pg_class c2,
pg_catalog.pg_index idx, pg_catalog.pg_attribute attr,
pg_catalog.pg_namespace n
WHERE c.oid = idx.indrelid
AND idx.indexrelid = c2.oid
AND attr.attrelid = c.oid
AND attr.attnum = idx.indkey[0]
AND c.relnamespace = n.oid
AND c.relname = %(table)s
AND n.nspname = %(schema)s
"""
_get_constaints_query = """
SELECT
kc.constraint_name,
kc.column_name,
c.constraint_type,
array(SELECT table_name::text || '.' || column_name::text
FROM information_schema.constraint_column_usage
WHERE constraint_name = kc.constraint_name)
FROM information_schema.key_column_usage AS kc
JOIN information_schema.table_constraints AS c ON
kc.table_schema = c.table_schema AND
kc.table_name = c.table_name AND
kc.constraint_name = c.constraint_name
WHERE
kc.table_schema = %(schema)s AND
kc.table_name = %(table)s
ORDER BY kc.ordinal_position ASC
"""
_get_check_constraints_query = """
SELECT kc.constraint_name, kc.column_name
FROM information_schema.constraint_column_usage AS kc
JOIN information_schema.table_constraints AS c ON
kc.table_schema = c.table_schema AND
kc.table_name = c.table_name AND
kc.constraint_name = c.constraint_name
WHERE
c.constraint_type = 'CHECK' AND
kc.table_schema = %(schema)s AND
kc.table_name = %(table)s
"""
_get_index_constraints_query = """
SELECT
c2.relname,
ARRAY(
SELECT (
SELECT attname
FROM pg_catalog.pg_attribute
WHERE attnum = i AND attrelid = c.oid
)
FROM unnest(idx.indkey) i
),
idx.indisunique,
idx.indisprimary
FROM pg_catalog.pg_class c, pg_catalog.pg_class c2,
pg_catalog.pg_index idx, pg_catalog.pg_namespace n
WHERE c.oid = idx.indrelid
AND idx.indexrelid = c2.oid
AND n.oid = c.relnamespace
AND c.relname = %(table)s
AND n.nspname = %(schema)s
"""
def get_table_list(self, cursor):
"Returns a list of table names in the current database and schema."
cursor.execute("""
SELECT c.relname, c.relkind
FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'v', '')
AND n.nspname = '%s'
AND pg_catalog.pg_table_is_visible(c.oid)""" % self.connection.schema_name)
if django.VERSION >= (1, 8, 0):
return [TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1]))
for row in cursor.fetchall()
if row[0] not in self.ignored_tables]
else:
return [row[0] for row in cursor.fetchall() if row[0] not in self.ignored_tables]
"""
Returns a list of table names in the current database and schema.
"""
cursor.execute(self._get_table_list_query, {
'schema': self.connection.schema_name,
})
return [
_build_table_info(row)
for row in cursor.fetchall()
if row[0] not in self.ignored_tables
]
def get_table_description(self, cursor, table_name):
"""
Returns a description of the table, with the DB-API
cursor.description interface.
"""
# As cursor.description does not return reliably the nullable property,
# we have to query the information_schema (#7783)
cursor.execute(self._get_table_description_query, {
'schema': self.connection.schema_name,
'table': table_name
})
field_map = {line[0]: line[1:] for line in cursor.fetchall()}
cursor.execute('SELECT * FROM %s.%s LIMIT 1' % (
self.connection.schema_name,
self.connection.ops.quote_name(table_name),
))
return [
_build_field_info(line, field_map)
for line in cursor.description
]
def get_relations(self, cursor, table_name):
"""
Returns a dictionary of
{field_name: (field_name_other_table, other_table)}
representing all relationships to the given table.
"""
cursor.execute(self._get_relations_query, {
'schema': self.connection.schema_name,
'table': table_name,
})
relations = {}
for row in cursor.fetchall():
relations[row[1]] = (row[2], row[0])
return relations
def get_key_columns(self, cursor, table_name):
cursor.execute(self._get_key_columns_query, {
'schema': self.connection.schema_name,
'table': table_name,
})
return list(cursor.fetchall())
def get_indexes(self, cursor, table_name):
# This query retrieves each index on the given table, including the
# first associated field name
cursor.execute(self._get_indexes_query, {
'schema': self.connection.schema_name,
'table': table_name,
})
indexes = {}
for row in cursor.fetchall():
# row[1] (idx.indkey) is stored in the DB as an array.
# It comes out as a string of space-separated integers.
# This designates the field indexes (1-based) of the fields
# that have indexes on the table. Here, we skip any indexes
# across multiple fields.
if ' ' in row[1]:
continue
if row[0] not in indexes:
indexes[row[0]] = {'primary_key': False, 'unique': False}
# It's possible to have the unique and PK constraints
# in separate indexes.
if row[3]:
indexes[row[0]]['primary_key'] = True
if row[2]:
indexes[row[0]]['unique'] = True
return indexes
def get_constraints(self, cursor, table_name):
"""
Retrieves any constraints or keys (unique, pk, fk, check, index)
across one or more columns.
"""
constraints = {}
# Loop over the key table, collecting things as constraints
# This will get PKs, FKs, and uniques, but not CHECK
cursor.execute(self._get_constaints_query, {
'schema': self.connection.schema_name,
'table': table_name,
})
for constraint, column, kind, used_cols in cursor.fetchall():
# If we're the first column, make the record
if constraint not in constraints:
constraints[constraint] = {
"columns": [],
"primary_key": kind.lower() == "primary key",
"unique": kind.lower() in ["primary key", "unique"],
"foreign_key":
tuple(used_cols[0].split(".", 1))
if kind.lower() == "foreign key"
else None,
"check": False,
"index": False,
}
# Record the details
constraints[constraint]['columns'].append(column)
# Now get CHECK constraint columns
cursor.execute(self._get_check_constraints_query, {
'schema': self.connection.schema_name,
'table': table_name,
})
for constraint, column in cursor.fetchall():
# If we're the first column, make the record
if constraint not in constraints:
constraints[constraint] = {
"columns": [],
"primary_key": False,
"unique": False,
"foreign_key": None,
"check": True,
"index": False,
}
# Record the details
constraints[constraint]['columns'].append(column)
# Now get indexes
cursor.execute(self._get_index_constraints_query, {
'schema': self.connection.schema_name,
'table': table_name,
})
for index, columns, unique, primary in cursor.fetchall():
if index not in constraints:
constraints[index] = {
"columns": list(columns),
"primary_key": primary,
"unique": unique,
"foreign_key": None,
"check": False,
"index": True,
}
return constraints

View File

@ -6,81 +6,92 @@ multi-tenant setting
import hashlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.template.base import TemplateDoesNotExist
from django.template.loader import (BaseLoader, get_template_from_string,
find_template_loader, make_origin)
from django.template.base import TemplateDoesNotExist, Template
from django.utils.encoding import force_bytes
from django.utils._os import safe_join
from django.db import connection
from tenant_schemas.postgresql_backend.base import FakeTenant
from django.template.loaders.base import Loader as BaseLoader
from tenant_schemas.postgresql_backend.base import FakeTenant
import django
class CachedLoader(BaseLoader):
is_usable = True
def __init__(self, loaders):
def __init__(self, engine, loaders):
self.template_cache = {}
self._loaders = loaders
self._cached_loaders = []
self.find_template_cache = {}
self.loaders = engine.get_template_loaders(loaders)
super(CachedLoader, self).__init__(engine)
@property
def loaders(self):
# Resolve loaders on demand to avoid circular imports
if not self._cached_loaders:
# Set self._cached_loaders atomically. Otherwise, another thread
# could see an incomplete list. See #17303.
cached_loaders = []
for loader in self._loaders:
cached_loaders.append(find_template_loader(loader))
self._cached_loaders = cached_loaders
return self._cached_loaders
@staticmethod
def cache_key(template_name, template_dirs):
if connection.tenant and template_dirs:
return '-'.join([str(connection.tenant.pk), template_name,
hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()])
if template_dirs:
# If template directories were specified, use a hash to differentiate
return '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()])
else:
return template_name
def find_template(self, name, dirs=None):
for loader in self.loaders:
try:
template, display_name = loader(name, dirs)
return template, make_origin(display_name, loader, name, dirs)
except TemplateDoesNotExist:
pass
raise TemplateDoesNotExist(name)
"""
Helper method. Lookup the template :param name: in all the configured loaders
"""
key = self.cache_key(name, dirs)
try:
result = self.find_template_cache[key]
except KeyError:
result = None
for loader in self.loaders:
try:
template, display_name = loader(name, dirs)
except TemplateDoesNotExist:
pass
else:
origin = self.engine.make_origin(display_name, loader, name, dirs)
result = template, origin
break
self.find_template_cache[key] = result
if result:
return result
else:
self.template_cache[key] = TemplateDoesNotExist
raise TemplateDoesNotExist(name)
def load_template(self, template_name, template_dirs=None):
if connection.tenant:
key = '-'.join([str(connection.tenant.pk), template_name])
else:
key = template_name
if template_dirs:
# If template directories were specified, use a hash to
# differentiate
if connection.tenant:
key = '-'.join([str(connection.tenant.pk), template_name,
hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()])
else:
key = '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()])
if key not in self.template_cache:
key = self.cache_key(template_name, template_dirs)
template_tuple = self.template_cache.get(key)
# A cached previous failure:
if template_tuple is TemplateDoesNotExist:
raise TemplateDoesNotExist
elif template_tuple is None:
template, origin = self.find_template(template_name, template_dirs)
if not hasattr(template, 'render'):
try:
template = get_template_from_string(template, origin, template_name)
template = Template(template, origin, template_name, self.engine)
except TemplateDoesNotExist:
# If compiling the template we found raises TemplateDoesNotExist,
# back off to returning the source and display name for the template
# we were asked to load. This allows for correct identification (later)
# of the actual template that does not exist.
return template, origin
self.template_cache[key] = template
return self.template_cache[key], None
self.template_cache[key] = (template, origin)
self.template_cache[key] = (template, None)
return self.template_cache[key]
def reset(self):
"Empty the template cache."
"""
Empty the template cache.
"""
self.template_cache.clear()
class FilesystemLoader(BaseLoader):
is_usable = True
def get_template_sources(self, template_name, template_dirs=None):
@staticmethod
def get_template_sources(template_name, template_dirs=None):
"""
Returns the absolute paths to "template_name", when appended to each
directory in "template_dirs". Any paths that don't lie inside one of the
@ -97,9 +108,9 @@ class FilesystemLoader(BaseLoader):
for template_dir in template_dirs:
try:
if '%s' in template_dir:
yield safe_join(template_dir % connection.tenant.domain_url, template_name)
yield safe_join(template_dir % connection.tenant.schema_name, template_name)
else:
yield safe_join(template_dir, connection.tenant.domain_url, template_name)
yield safe_join(template_dir, connection.tenant.schema_name, template_name)
except UnicodeDecodeError:
# The template dir name was a bytestring that wasn't valid UTF-8.
raise
@ -114,7 +125,7 @@ class FilesystemLoader(BaseLoader):
for filepath in self.get_template_sources(template_name, template_dirs):
try:
with open(filepath, 'rb') as fp:
return (fp.read().decode(settings.FILE_CHARSET), filepath)
return fp.read().decode(settings.FILE_CHARSET), filepath
except IOError:
tried.append(filepath)
if tried:

View File

@ -1,10 +1,9 @@
import django
from django.core.management import call_command
from django.db import connection
from django.test import TestCase
from tenant_schemas.utils import get_tenant_model
from tenant_schemas.utils import get_public_schema_name
from tenant_schemas.utils import get_tenant_model
class TenantTestCase(TestCase):
@ -27,17 +26,7 @@ class TenantTestCase(TestCase):
@classmethod
def sync_shared(cls):
if django.VERSION >= (1, 7, 0):
call_command('migrate_schemas',
schema_name=get_public_schema_name(),
interactive=False,
verbosity=0)
else:
call_command('sync_schemas',
schema_name=get_public_schema_name(),
tenant=False,
public=True,
interactive=False,
migrate_all=True,
verbosity=0,
)
call_command('migrate_schemas',
schema_name=get_public_schema_name(),
interactive=False,
verbosity=0)

View File

@ -1,12 +1,12 @@
import django
import json
from StringIO import StringIO
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management import call_command
from django.db import connection
from StringIO import StringIO
from dts_test_app.models import DummyModel, ModelWithFkToPublicUser
from tenant_schemas.test.cases import TenantTestCase
from tenant_schemas.tests.models import Tenant, NonAutoSyncTenant
from tenant_schemas.tests.testcases import BaseTestCase
@ -124,7 +124,7 @@ class TenantSyncTest(BaseTestCase):
Tests if the shared apps and the tenant apps get synced correctly
depending on if the public schema or a tenant is being synced.
"""
MIGRATION_TABLE_SIZE = 1 if django.VERSION >= (1, 7, 0) else 0
MIGRATION_TABLE_SIZE = 1
def test_shared_apps_does_not_sync_tenant_apps(self):
"""
@ -217,12 +217,11 @@ class TenantCommandTest(BaseTestCase):
Tenant(domain_url='localhost', schema_name='public').save(verbosity=BaseTestCase.get_verbosity())
out = StringIO()
if django.VERSION >= (1, 8, 0):
call_command('tenant_command', args=('dumpdata', 'tenant_schemas'), natural_foreign=True,
schema_name=get_public_schema_name(), stdout=out)
else:
call_command('tenant_command', 'dumpdata', 'tenant_schemas', natural_foreign=True,
schema_name=get_public_schema_name(), stdout=out)
call_command('tenant_command',
args=('dumpdata', 'tenant_schemas'),
natural_foreign=True,
schema_name=get_public_schema_name(),
stdout=out)
self.assertItemsEqual(
json.loads('[{"fields": {"domain_url": "localhost", "schema_name": "public"}, '
'"model": "tenant_schemas.tenant", "pk": 1}]'),

View File

@ -0,0 +1,53 @@
from __future__ import absolute_import
import sys
import types
from django.apps import AppConfig
from django.test import TestCase
from tenant_schemas import utils
class AppLabelsTestCase(TestCase):
def setUp(self):
self._modules = set()
def tearDown(self):
for name in self._modules:
sys.modules.pop(name, None)
def set_up_module(self, whole_name):
parts = whole_name.split('.')
name = ''
for part in parts:
name += ('.%s' % part) if name else part
module = types.ModuleType(name)
module.__path__ = ['/tmp']
self._modules.add(name)
sys.modules[name] = module
return sys.modules[whole_name]
def test_app_labels(self):
"""
Verifies that app_labels handle Django 1.7+ AppConfigs properly.
https://docs.djangoproject.com/en/1.7/ref/applications/
"""
self.set_up_module('example1')
apps = self.set_up_module('example2.apps')
# set up AppConfig on the `test_app.apps` module
class Example2AppConfig(AppConfig):
name = 'example2'
label = 'example2_app' # with different name
path = '/tmp' # for whatever reason path is required
apps.Example2AppConfig = Example2AppConfig
self.assertEqual(
utils.app_labels([
'example1',
'example2.apps.Example2AppConfig'
]),
['example1', 'example2_app'],
)

View File

@ -52,17 +52,8 @@ class BaseTestCase(TestCase):
@classmethod
def sync_shared(cls):
if django.VERSION >= (1, 7, 0):
call_command('migrate_schemas',
schema_name=get_public_schema_name(),
interactive=False,
verbosity=cls.get_verbosity(),
run_syncdb=True)
else:
call_command('sync_schemas',
schema_name=get_public_schema_name(),
tenant=False,
public=True,
interactive=False,
migrate_all=True,
verbosity=cls.get_verbosity())
call_command('migrate_schemas',
schema_name=get_public_schema_name(),
interactive=False,
verbosity=cls.get_verbosity(),
run_syncdb=True)

View File

@ -4,10 +4,11 @@ from django.conf import settings
from django.db import connection
try:
from django.apps import apps
from django.apps import apps, AppConfig
get_model = apps.get_model
except ImportError:
from django.db.models.loading import get_model
AppConfig = None
from django.core import mail
@ -108,6 +109,12 @@ def schema_exists(schema_name):
def app_labels(apps_list):
"""
Returns a list of app labels of the given apps_list
Returns a list of app labels of the given apps_list, now properly handles
new Django 1.7+ application registry.
https://docs.djangoproject.com/en/1.8/ref/applications/#django.apps.AppConfig.label
"""
return [app.split('.')[-1] for app in apps_list]
if AppConfig is None:
return [app.split('.')[-1] for app in apps_list]
return [AppConfig.create(app).label for app in apps_list]

View File

@ -1,5 +1,5 @@
[tox]
envlist = py27-dj{17,18,19}
envlist = py27-dj{18,19}
[testenv]
basepython =
@ -7,7 +7,6 @@ basepython =
usedevelop = True
deps =
dj17: Django~=1.7.0
dj18: Django~=1.8.0
dj19: Django~=1.9.0