diff --git a/.travis.yml b/.travis.yml index 3769b5a..845cc71 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,22 @@ +sudo: false language: python python: -- 2.7 -- 3.5 + - 2.7 + - 3.5 services: -- postgresql + - postgresql addons: postgresql: '9.4' -install: -- pip install tox -before_script: -- psql -c "CREATE DATABASE dts_test_project;" -U postgres -script: -- tox -e py${TRAVIS_PYTHON_VERSION/./}-dj${DJANGO/./} +install: pip install -q tox-travis env: -- DJANGO=1.8 -- DJANGO=1.10 -- DJANGO=1.11 + - DJANGO=1.8 + - DJANGO=1.9 + - DJANGO=1.10 + - DJANGO=1.11 +matrix: + fast_finish: true +script: tox +before_script: psql -c "CREATE DATABASE dts_test_project;" -U postgres deploy: provider: pypi user: bcarneiro diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst new file mode 100644 index 0000000..85be259 --- /dev/null +++ b/docs/advanced_usage.rst @@ -0,0 +1,61 @@ +============== +Advanced Usage +============== + +Custom tenant strategies (custom middleware support) +==================================================== +By default, ``django-tenant-schemas``'s strategies for determining the correct tenant involve extracting it from the URL (e.g. ``mytenant.mydomain.com``). This is done through a middleware, typically ``TenantMiddleware``. + +In some situations, it might be useful to use **alternative tenant selection strategies**. For example, consider a website with a fixed URL. An approach for this website might be to pass the tenant through a special header, or to determine it in some other manner based on the request (e.g. using an OAuth token mapped to a tenant). ``django-tenant-schemas`` offer an **easily extensible way to provide your own middleware** with minimal code changes. + +To add custom tenant selection strategies, you need to **subclass the** ``BaseTenantMiddleware`` **class and implement its** ``get_tenant`` **method**. This method accepts the current ``request`` object through which you can determine the tenant to use. In addition, for backwards-compatibility reasons, the method also accepts the tenant model class (``TENANT_MODEL``) and the ``hostname`` of the current request. **You should return an instance of your** ``TENANT_MODEL`` **class** from this function. +After creating your middleware, you should make it the top-most middleware in your list. You should only have one subclass of ``BaseTenantMiddleware`` per project. + +Note that you might also wish to extend the other provided middleware classes, such as ``TenantMiddleware``. For example, you might want to chain several strategies together, and you could do so by subclassing the original strategies and manipulating the call to ``super``'s ``get_tenant``. + + +Example: Determine tenant from HTTP header +------------------------------------------ +Suppose you wanted to determine the current tenant based on a request header (``X-DTS-SCHEMA``). You might implement a simple middleware such as: + +.. code-block:: python + + class XHeaderTenantMiddleware(BaseTenantMiddleware): + """ + Determines tenant by the value of the ``X-DTS-SCHEMA`` HTTP header. + """ + def get_tenant(self, model, hostname, request): + schema_name = request.META.get('HTTP_X_DTS_SCHEMA', get_public_schema_name()) + return model.objects.get(schema_name=schema_name) + +Your application could now specify the tenant with the ``X-DTS-SCHEMA`` HTTP header. In scenarios where you are configuring individual tenant websites by yourself, each with its own ``nginx`` configuration to redirect to the right tenant, you could use a configuration such as the one below: + + +.. code-block:: nginx + + # /etc/nginx/conf.d/multitenant.conf + + upstream web { + server localhost:8000; + } + + server { + listen 80 default_server; + server_name _; + + location / { + proxy_pass http://web; + proxy_set_header Host $host; + } + } + + server { + listen 80; + server_name example.com www.example.com; + + location / { + proxy_pass http://web; + proxy_set_header Host $host; + proxy_set_header X-DTS-SCHEMA example; # triggers XHeaderTenantMiddleware + } + } diff --git a/docs/conf.py b/docs/conf.py index 971871a..222c47d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,8 +11,6 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os import datetime # If extensions (or modules to document with autodoc) are in another directory, @@ -24,7 +22,7 @@ import datetime # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.autosectionlabel',] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index d47d9b4..e890259 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,9 +48,10 @@ Contents .. toctree:: :maxdepth: 2 - + install use + advanced_usage examples templates test diff --git a/docs/install.rst b/docs/install.rst index 26f18fc..e0c2124 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -38,6 +38,8 @@ If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use t If you'd like to serve the public tenant for unrecognised hostnames instead, use ``tenant_schemas.middleware.DefaultTenantMiddleware``. To use a tenant other than the public tenant, create a subclass and register it instead. +If you'd like a different tenant selection technique (e.g. using an HTTP Header), you can define a custom middleware. See :ref:`Advanced Usage`. + .. code-block:: python from tenant_schemas.middleware import DefaultTenantMiddleware @@ -173,12 +175,6 @@ Optional Settings The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. -.. attribute:: TENANT_CREATION_FAKES_MIGRATIONS - - :Default: ``'True'`` - - Sets if the models will be synced directly to the last version and all migration subsequently faked. Useful in the cases where migrations can not be faked and need to be ran individually. Be aware that setting this to `False` may significantly slow down the process of creating tenants. - Tenant View-Routing ------------------- diff --git a/docs/test.rst b/docs/test.rst index 65968c6..2f5d8b8 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -36,9 +36,7 @@ Because django will not create tenants for you during your tests, we have packed Running tests faster -------------------- -Using the ``TenantTestCase`` can make running your tests really slow quite early in your project. This is due to the fact that it drops, recreates the test schema and runs migrations for every ``TenantTestCase`` you have. If you want to gain speed, there's a ``FastTenantTestCase`` where the test schema will be created and migrations ran only one time. The gain in speed is noticiable but be aware that by using this you will be perpertraiting state between your test cases, please make sure your they wont be affected by this. - -Running tests using ``TenantTestCase`` can start being a bottleneck once the number of tests grow. If you do not care that the state between tests is kept, an alternative is to use the class ``FastTenantTestCase``. Unlike ``TenantTestCase``, the test schema and its migrations will only be created and ran once. This is a significant improvement in speed coming at the cost of shared state. +Running tests using ``TenantTestCase`` can start being a bottleneck once the number of tests grow. ``TenantTestCase`` drops, recreates and executes migrations for the test schema every time for every ``TenantTestCase`` you have. If you do not care that the state between tests is kept, an alternative is to use the class ``FastTenantTestCase``. Unlike ``TenantTestCase``, the test schema and its migrations will only be created and ran once. This is a significant improvement in speed coming at the cost of shared state. .. code-block:: python diff --git a/docs/use.rst b/docs/use.rst index 4085fba..e2c98f2 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -98,6 +98,26 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H ``migrate_schemas`` raises an exception when an tenant schema is missing. +migrate_schemas in parallel +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once the number of tenants grow, migrating all the tenants can become a bottleneck. To speed up this process, you can run tenant migrations in parallel like this: + +.. code-block:: bash + + python manage.py migrate_schemas --executor=parallel + +In fact, you can write your own executor which will run tenant migrations in +any way you want, just take a look at ``tenant_schemas/migration_executors``. + +The ``parallel`` executor accepts the following settings: + +* ``TENANT_PARALLEL_MIGRATION_MAX_PROCESSES`` (default: 2) - maximum number of + processes for migration pool (this is to avoid exhausting the database + connection pool) +* ``TENANT_PARALLEL_MIGRATION_CHUNKS`` (default: 2) - number of migrations to be + sent at once to every worker + tenant_command ~~~~~~~~~~~~~~ diff --git a/setup.py b/setup.py index c201fd6..6b8613a 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python from os.path import exists + from version import get_git_version try: @@ -15,6 +16,7 @@ setup( author_email='carneiro.be@gmail.com', packages=[ 'tenant_schemas', + 'tenant_schemas.migration_executors', 'tenant_schemas.postgresql_backend', 'tenant_schemas.management', 'tenant_schemas.management.commands', diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py index d644a96..6b16b62 100644 --- a/tenant_schemas/apps.py +++ b/tenant_schemas/apps.py @@ -51,12 +51,14 @@ def best_practice(app_configs, **kwargs): obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " - "implementations.")) + "implementations.", + id="tenant_schemas.W001")) if not settings.TENANT_APPS: errors.append( Error("TENANT_APPS is empty.", - hint="Maybe you don't need this app?")) + hint="Maybe you don't need this app?", + id="tenant_schemas.E001")) if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: @@ -74,25 +76,29 @@ def best_practice(app_configs, **kwargs): if not settings.SHARED_APPS: errors.append( - Warning("SHARED_APPS is empty.")) + Warning("SHARED_APPS is empty.", + id="tenant_schemas.W002")) if not set(settings.TENANT_APPS).issubset(INSTALLED_APPS): delta = set(settings.TENANT_APPS).difference(INSTALLED_APPS) errors.append( Error("You have TENANT_APPS that are not in INSTALLED_APPS", - hint=[a for a in settings.TENANT_APPS if a in delta])) + hint=[a for a in settings.TENANT_APPS if a in delta], + id="tenant_schemas.E002")) if not set(settings.SHARED_APPS).issubset(INSTALLED_APPS): delta = set(settings.SHARED_APPS).difference(INSTALLED_APPS) errors.append( Error("You have SHARED_APPS that are not in INSTALLED_APPS", - hint=[a for a in settings.SHARED_APPS if a in delta])) + hint=[a for a in settings.SHARED_APPS if a in delta], + id="tenant_schemas.E003")) if not isinstance(default_storage, TenantStorageMixin): errors.append(Warning( "Your default storage engine is not tenant aware.", hint="Set settings.DEFAULT_FILE_STORAGE to " "'tenant_schemas.storage.TenantFileSystemStorage'", + id="tenant_schemas.W003" )) return errors diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 54c4711..c3c28ed 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -149,11 +149,14 @@ class SyncCommon(BaseCommand): help=('Database state will be brought to the state after that ' 'migration. Use the name "zero" to unapply all migrations.')) parser.add_argument("-s", "--schema", dest="schema_name") + parser.add_argument('--executor', action='store', dest='executor', default=None, + help='Executor for running migrations [standard (default)|parallel]') def handle(self, *args, **options): self.sync_tenant = options.get('tenant') self.sync_public = options.get('shared') self.schema_name = options.get('schema_name') + self.executor = options.get('executor') self.installed_apps = settings.INSTALLED_APPS self.args = args self.options = options diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 7878629..7e131c2 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -1,11 +1,10 @@ import django - -from django.conf import settings from django.core.management.commands.migrate import Command as MigrateCommand from django.db import connection from tenant_schemas.management.commands import SyncCommon -from tenant_schemas.utils import get_tenant_model, get_public_schema_name, schema_exists +from tenant_schemas.migration_executors import get_executor +from tenant_schemas.utils import get_public_schema_name, get_tenant_model, schema_exists if django.VERSION >= (1, 9, 0): from django.db.migrations.exceptions import MigrationSchemaMissing @@ -34,35 +33,21 @@ class Command(SyncCommon): super(Command, self).handle(*args, **options) self.PUBLIC_SCHEMA_NAME = get_public_schema_name() + executor = get_executor(codename=self.executor)(self.args, self.options) + if self.sync_public and not self.schema_name: self.schema_name = self.PUBLIC_SCHEMA_NAME if self.sync_public: - self.run_migrations(self.schema_name, settings.SHARED_APPS) + executor.run_migrations(tenants=[self.schema_name]) if self.sync_tenant: if self.schema_name and self.schema_name != self.PUBLIC_SCHEMA_NAME: if not schema_exists(self.schema_name): raise MigrationSchemaMissing('Schema "{}" does not exist'.format( self.schema_name)) else: - self.run_migrations(self.schema_name, settings.TENANT_APPS) + tenants = [self.schema_name] else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) - for tenant in all_tenants: - self.run_migrations(tenant.schema_name, settings.TENANT_APPS) - - def run_migrations(self, schema_name, included_apps): - if int(self.options.get('verbosity', 1)) >= 1: - self._notice("=== Running migrate for schema %s" % schema_name) - - if not schema_exists(schema_name): - raise MigrationSchemaMissing('Schema "{}" does not exist'.format( - schema_name)) - - connection.set_schema(schema_name) - command = MigrateCommand() - command.execute(*self.args, **self.options) - connection.set_schema_to_public() - - def _notice(self, output): - self.stdout.write(self.style.NOTICE(output)) + tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()).values_list( + 'schema_name', flat=True) + executor.run_migrations(tenants=tenants) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index fa84094..639e686 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -13,38 +13,54 @@ if django.VERSION >= (1, 10, 0): else: MIDDLEWARE_MIXIN = object +""" +These middlewares should be placed at the very top of the middleware stack. +Selects the proper database schema using request information. Can fail in +various ways which is better than corrupting or revealing data. -class TenantMiddleware(MIDDLEWARE_MIXIN): - """ - This middleware should be placed at the very top of the middleware stack. - Selects the proper database schema using the request host. Can fail in - various ways which is better than corrupting or revealing data. - """ +Extend BaseTenantMiddleware for a custom tenant selection strategy, +such as inspecting the header, or extracting it from some OAuth token. +""" + +class BaseTenantMiddleware(MIDDLEWARE_MIXIN): TENANT_NOT_FOUND_EXCEPTION = Http404 + """ + Subclass and override this to achieve desired behaviour. Given a + request, return the tenant to use. Tenant should be an instance + of TENANT_MODEL. We have three parameters for backwards compatibility + (the request would be enough). + """ + def get_tenant(self, model, hostname, request): + raise NotImplementedError + def hostname_from_request(self, request): """ Extracts hostname from request. Used for custom requests filtering. By default removes the request's port and common prefixes. """ return remove_www(request.get_host().split(':')[0]).lower() - def get_tenant(self, model, hostname, request): - return model.objects.get(domain_url=hostname) - def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. connection.set_schema_to_public() - hostname = self.hostname_from_request(request) + hostname = self.hostname_from_request(request) TenantModel = get_tenant_model() try: - request.tenant = self.get_tenant(TenantModel, hostname, request) - connection.set_tenant(request.tenant) + # get_tenant must be implemented by extending this class. + tenant = self.get_tenant(TenantModel, hostname, request) + assert isinstance(tenant, TenantModel) except TenantModel.DoesNotExist: raise self.TENANT_NOT_FOUND_EXCEPTION( - 'No tenant for hostname "%s"' % hostname) + 'No tenant for {!r}'.format(request.get_host())) + except AssertionError: + raise self.TENANT_NOT_FOUND_EXCEPTION( + 'Invalid tenant {!r}'.format(request.tenant)) + + request.tenant = tenant + connection.set_tenant(request.tenant) # Content type can no longer be cached as public and tenant schemas # have different models. If someone wants to change this, the cache @@ -59,6 +75,14 @@ class TenantMiddleware(MIDDLEWARE_MIXIN): if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF') and request.tenant.schema_name == get_public_schema_name(): request.urlconf = settings.PUBLIC_SCHEMA_URLCONF +class TenantMiddleware(BaseTenantMiddleware): + """ + Selects the proper database schema using the request host. E.g. . + """ + + def get_tenant(self, model, hostname, request): + return model.objects.get(domain_url=hostname) + class SuspiciousTenantMiddleware(TenantMiddleware): """ diff --git a/tenant_schemas/migration_executors/__init__.py b/tenant_schemas/migration_executors/__init__.py new file mode 100644 index 0000000..b83db18 --- /dev/null +++ b/tenant_schemas/migration_executors/__init__.py @@ -0,0 +1,15 @@ +import os + +from tenant_schemas.migration_executors.base import MigrationExecutor +from tenant_schemas.migration_executors.parallel import ParallelExecutor +from tenant_schemas.migration_executors.standard import StandardExecutor + + +def get_executor(codename=None): + codename = codename or os.environ.get('EXECUTOR', StandardExecutor.codename) + + for klass in MigrationExecutor.__subclasses__(): + if klass.codename == codename: + return klass + + raise NotImplementedError('No executor with codename %s' % codename) diff --git a/tenant_schemas/migration_executors/base.py b/tenant_schemas/migration_executors/base.py new file mode 100644 index 0000000..5ce6c59 --- /dev/null +++ b/tenant_schemas/migration_executors/base.py @@ -0,0 +1,64 @@ +import sys + +from django.core.management.commands.migrate import Command as MigrateCommand +from django.db import transaction + +from tenant_schemas.utils import get_public_schema_name + + +def run_migrations(args, options, executor_codename, schema_name, allow_atomic=True): + from django.core.management import color + from django.core.management.base import OutputWrapper + from django.db import connection + + style = color.color_style() + + def style_func(msg): + return '[%s:%s] %s' % ( + style.NOTICE(executor_codename), + style.NOTICE(schema_name), + msg + ) + + stdout = OutputWrapper(sys.stdout) + stdout.style_func = style_func + stderr = OutputWrapper(sys.stderr) + stderr.style_func = style_func + if int(options.get('verbosity', 1)) >= 1: + stdout.write(style.NOTICE("=== Running migrate for schema %s" % schema_name)) + + connection.set_schema(schema_name) + MigrateCommand(stdout=stdout, stderr=stderr).execute(*args, **options) + + try: + transaction.commit() + connection.close() + connection.connection = None + except transaction.TransactionManagementError: + if not allow_atomic: + raise + + # We are in atomic transaction, don't close connections + pass + + connection.set_schema_to_public() + + +class MigrationExecutor(object): + codename = None + + def __init__(self, args, options): + self.args = args + self.options = options + + def run_migrations(self, tenants): + public_schema_name = get_public_schema_name() + + if public_schema_name in tenants: + run_migrations(self.args, self.options, self.codename, public_schema_name) + tenants.pop(tenants.index(public_schema_name)) + + self.run_tenant_migrations(tenants) + + def run_tenant_migrations(self, tenant): + raise NotImplementedError diff --git a/tenant_schemas/migration_executors/parallel.py b/tenant_schemas/migration_executors/parallel.py new file mode 100644 index 0000000..9228378 --- /dev/null +++ b/tenant_schemas/migration_executors/parallel.py @@ -0,0 +1,30 @@ +import functools +import multiprocessing + +from django.conf import settings + +from tenant_schemas.migration_executors.base import MigrationExecutor, run_migrations + + +class ParallelExecutor(MigrationExecutor): + codename = 'parallel' + + def run_tenant_migrations(self, tenants): + if tenants: + processes = getattr(settings, 'TENANT_PARALLEL_MIGRATION_MAX_PROCESSES', 2) + chunks = getattr(settings, 'TENANT_PARALLEL_MIGRATION_CHUNKS', 2) + + from django.db import connection + + connection.close() + connection.connection = None + + run_migrations_p = functools.partial( + run_migrations, + self.args, + self.options, + self.codename, + allow_atomic=False + ) + p = multiprocessing.Pool(processes=processes) + p.map(run_migrations_p, tenants, chunks) diff --git a/tenant_schemas/migration_executors/standard.py b/tenant_schemas/migration_executors/standard.py new file mode 100644 index 0000000..be5eb2d --- /dev/null +++ b/tenant_schemas/migration_executors/standard.py @@ -0,0 +1,9 @@ +from tenant_schemas.migration_executors.base import MigrationExecutor, run_migrations + + +class StandardExecutor(MigrationExecutor): + codename = 'standard' + + def run_tenant_migrations(self, tenants): + for schema_name in tenants: + run_migrations(self.args, self.options, self.codename, schema_name) diff --git a/tenant_schemas/models.py b/tenant_schemas/models.py index c507fcf..d51aa34 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -6,6 +6,27 @@ from tenant_schemas.signals import post_schema_sync from tenant_schemas.utils import get_public_schema_name, schema_exists +class TenantQueryset(models.QuerySet): + """ + QuerySet for instances that inherit from the TenantMixin. + """ + def delete(self): + """ + Make sure we call the delete method of each object in the queryset so + that safety checks and schema deletion (if requested) are executed + even when using bulk delete. + """ + counter, counter_dict = 0, {} + for obj in self: + result = obj.delete() + if result is not None: + current_counter, current_counter_dict = result + counter += current_counter + counter_dict.update(current_counter_dict) + if counter: + return counter, counter_dict + + class TenantMixin(models.Model): """ All tenant models must inherit this class. @@ -27,6 +48,7 @@ class TenantMixin(models.Model): domain_url = models.CharField(max_length=128, unique=True) schema_name = models.CharField(max_length=63, unique=True, validators=[_check_schema_name]) + objects = TenantQueryset.as_manager() class Meta: abstract = True @@ -69,7 +91,7 @@ class TenantMixin(models.Model): cursor = connection.cursor() cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE' % self.schema_name) - super(TenantMixin, self).delete(*args, **kwargs) + return super(TenantMixin, self).delete(*args, **kwargs) def create_schema(self, check_if_exists=False, sync_schema=True, verbosity=1): diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index 08555aa..6e940fa 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -9,6 +9,7 @@ import django.db.utils from tenant_schemas.utils import get_public_schema_name, get_limit_set_calls from tenant_schemas.postgresql_backend.introspection import DatabaseSchemaIntrospection + ORIGINAL_BACKEND = getattr(settings, 'ORIGINAL_BACKEND', 'django.db.backends.postgresql_psycopg2') # Django 1.9+ takes care to rename the default backend to 'django.db.backends.postgresql' original_backend = django.db.utils.load_backend(ORIGINAL_BACKEND) diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index cd05c6b..4fe8b36 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -1,10 +1,12 @@ +from django.conf import settings from django.core.management import call_command from django.conf import settings from django.db import connection from django.test import TestCase -from tenant_schemas.utils import get_public_schema_name -from tenant_schemas.utils import get_tenant_model +from tenant_schemas.utils import get_public_schema_name, get_tenant_model + +ALLOWED_TEST_DOMAIN = '.test.com' ALLOWED_TEST_DOMAIN = '.test.com' diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py index a14abd7..e0df917 100644 --- a/tenant_schemas/tests/test_apps.py +++ b/tenant_schemas/tests/test_apps.py @@ -63,7 +63,8 @@ class AppConfigTests(TestCase): obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " - "implementations."), + "implementations.", + id="tenant_schemas.W001"), ]) @override_settings(INSTALLED_APPS=[ @@ -83,7 +84,8 @@ class AppConfigTests(TestCase): def test_tenant_apps_empty(self): self.assertBestPractice([ Error("TENANT_APPS is empty.", - hint="Maybe you don't need this app?"), + hint="Maybe you don't need this app?", + id="tenant_schemas.E001"), ]) @override_settings(PG_EXTRA_SEARCH_PATHS=['public', 'demo1', 'demo2']) @@ -101,7 +103,8 @@ class AppConfigTests(TestCase): @override_settings(SHARED_APPS=()) def test_shared_apps_empty(self): self.assertBestPractice([ - Warning("SHARED_APPS is empty."), + Warning("SHARED_APPS is empty.", + id="tenant_schemas.W002"), ]) @override_settings(TENANT_APPS=( @@ -111,7 +114,8 @@ class AppConfigTests(TestCase): def test_tenant_app_missing_from_install_apps(self): self.assertBestPractice([ Error("You have TENANT_APPS that are not in INSTALLED_APPS", - hint=['django.contrib.flatpages']), + hint=['django.contrib.flatpages'], + id="tenant_schemas.E002"), ]) @override_settings(SHARED_APPS=( @@ -127,5 +131,6 @@ class AppConfigTests(TestCase): def test_shared_app_missing_from_install_apps(self): self.assertBestPractice([ Error("You have SHARED_APPS that are not in INSTALLED_APPS", - hint=['django.contrib.flatpages']), + hint=['django.contrib.flatpages'], + id="tenant_schemas.E003"), ]) diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 5dd27e7..2bf26d8 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -99,6 +99,35 @@ class TenantDataAndSettingsTest(BaseTestCase): self.assertFalse(schema_exists(tenant.schema_name)) Tenant.auto_drop_schema = False + def test_auto_drop_schema_bulk_delete(self): + """ + When bulk deleting tenants, it should also drop the schemas of + tenants that have auto_drop_schema set to True. + """ + Tenant.auto_drop_schema = True + schemas = ['auto_drop_schema1', 'auto_drop_schema2'] + for schema in schemas: + self.assertFalse(schema_exists(schema)) + tenant = Tenant( + domain_url='%s.test.com' % schema, + schema_name=schema + ) + tenant.save(verbosity=BaseTestCase.get_verbosity()) + self.assertTrue(schema_exists(tenant.schema_name)) + + # Force pending trigger events to be executed + cursor = connection.cursor() + cursor.execute('SET CONSTRAINTS ALL IMMEDIATE') + + # get a queryset of our 2 tenants and do a bulk delete + Tenant.objects.filter(schema_name__in=schemas).delete() + + # verify that the schemas where deleted + for schema in schemas: + self.assertFalse(schema_exists(schema)) + + Tenant.auto_drop_schema = False + def test_switching_search_path(self): tenant1 = Tenant(domain_url='something.test.com', schema_name='tenant1') diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 558eafa..964c76f 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -1,4 +1,5 @@ import inspect + from django.conf import settings from django.core.management import call_command from django.db import connection diff --git a/tox.ini b/tox.ini index ffea785..839fe27 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,12 @@ [tox] -envlist = py{27,35}-dj{18,110,111} +envlist = py{27,35}-dj{18,19,110,111}-{standard,parallel} + +[travis:env] +DJANGO = + 1.8: dj18-{standard,parallel} + 1.9: dj19-{standard,parallel} + 1.10: dj110-{standard,parallel} + 1.11: dj111-{standard,parallel} [testenv] usedevelop = True @@ -17,6 +24,10 @@ changedir = dts_test_project passenv = PG_NAME PG_USER PG_PASSWORD PG_HOST PG_PORT +setenv = + standard: MIGRATION_EXECUTOR=standard + parallel: MIGRATION_EXECUTOR=parallel + commands = coverage run manage.py test --noinput {posargs:tenant_schemas} coverage report -m --include=../tenant_schemas/*