diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3b2bec5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{js,py}] +charset = utf-8 + +[*.py] +indent_style = space +indent_size = 4 + +[*.{css,js,less}] +indent_style = space +indent_size = 2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d83cf90 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,27 @@ +sudo: false +language: python +python: + - 2.7 + - 3.5 +services: + - postgresql +addons: + postgresql: '9.4' +install: pip install -q tox-travis +env: + - DJANGO=1.8 + - 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 + password: + secure: IJ23U+zTqDmHB8aB8GEhAAZRzOx5CorPe4Mi6KEyafuALWsY5CB95sN24Il01rhPX+3a88krwu+XNhsjEOVjNn4FE1sEiqO15p7hvIh5sj8JxjVqFvU58rScFTsvYIw4DwvO7cZSOQhFihEHtQRHbWd8b95GGqwRjh2yN1HzMsgOrptQC0Op038S4pSeFRGINj1HJbj320ctbLJoP9GBROpMJ71GR2V2RnTunxcMmlx4yVYEIHS1JijcDQ2BlG8ZikIVfeKqoipzd592Sq2JUuhPgL2MMeI8onHFGRJqUJv3elXev4qcXSqclQtGyTOewB5YZBHrqj3Pg134RmiOxapNWe0HaLPjsrflviJuZepU1ETh6epjcbZi4vl32MCQF/v1nhezoP4BLijJWstKmgmwXuFoDra6niUPcU97e1o5OnFkySlV9IcH+vrAo+sNGt0ztRen3zU+cab8ff0CnH1Me23Tzf3H6gkZCaHlol0xB5u75+5QSpgNAlgQfcbpZVqYsRPpCf61qJixfy/QPBydAWQ5uwG6KzgGTffAmAj4WOa3/n+19jsPNS6h0+VNEgRnoGfIzckQ/hrOV9wPIJ5oJ99SNYLnQ4SLl3JqqttryEHyBSRdYQ7wn3pb9oPfq58/47f/NTPNZRAsemVEx+9udm25+AtEAtS3OB6sQgQ= + on: + distributions: sdist bdist_wheel + repo: bernardopires/django-tenant-schemas + branch: master diff --git a/README.rst b/README.rst index 3d68782..e02f950 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ django-tenant-schemas ===================== -|PyPi version| |PyPi downloads| +|PyPi version| |PyPi downloads| |Python versions| |Travis CI| |PostgreSQL| This application enables `django`_ powered websites to have multiple tenants via `PostgreSQL schemas`_. A vital feature for every @@ -9,7 +9,7 @@ Software-as-a-Service website. Django provides currently no simple way to support multiple tenants using the same project instance, even when only the data is different. -Because we don’t want you running many copies of your project, you’ll be +Because we don't want you running many copies of your project, you'll be able to have: - Multiple customers running on the same instance @@ -20,10 +20,10 @@ What are schemas ---------------- A schema can be seen as a directory in an operating system, each -directory (schema) with it’s own set of files (tables and objects). This +directory (schema) with it's own set of files (tables and objects). This allows the same table name and objects to be used in different schemas without conflict. For an accurate description on schemas, see -`PostgreSQL’s official documentation on schemas`_. +`PostgreSQL's official documentation on schemas`_. Why schemas ----------- @@ -31,7 +31,7 @@ Why schemas There are typically three solutions for solving the multitenancy problem. -1. Isolated Approach: Separate Databases. Each tenant has it’s own +1. Isolated Approach: Separate Databases. Each tenant has it's own database. 2. Semi Isolated Approach: Shared Database, Separate Schemas. One @@ -48,8 +48,8 @@ represents the ideal compromise between simplicity and performance. multitenancy. Plus, you only manage one database. - Performance: make use of shared connections, buffers and memory. -Each solution has it’s up and down sides, for a more in-depth -discussion, see Microsoft’s excellent article on `Multi-Tenant Data +Each solution has it's up and down sides, for a more in-depth +discussion, see Microsoft's excellent article on `Multi-Tenant Data Architecture`_. How it works @@ -58,11 +58,11 @@ How it works Tenants are identified via their host name (i.e tenant.domain.com). This information is stored on a table on the ``public`` schema. Whenever a request is made, the host name is used to match a tenant in the -database. If there’s a match, the search path is updated to use this -tenant’s schema. So from now on all queries will take place at the -tenant’s schema. For example, suppose you have a tenant ``customer`` at +database. If there's a match, the search path is updated to use this +tenant's schema. So from now on all queries will take place at the +tenant's schema. For example, suppose you have a tenant ``customer`` at http://customer.example.com. Any request incoming at -``customer.example.com`` will automatically use ``customer``\ ’s schema +``customer.example.com`` will automatically use ``customer``\ 's schema and make the tenant available at the request. If no tenant is found, a 404 error is raised. This also means you should have a tenant for your main domain, typically using the ``public`` schema. For more information @@ -94,7 +94,7 @@ the host name to identify which view to serve. Magic ~~~~~ -Everyone loves magic! You’ll be able to have all this barely having to +Everyone loves magic! You'll be able to have all this barely having to change your code! Setup & Documentation @@ -102,7 +102,7 @@ Setup & Documentation **This is just a short setup guide**, it is **strongly** recommended that you read the complete version at -`django-tenant-schemas.readthedocs.org`_. +`django-tenant-schemas.readthedocs.io`_. Your ``DATABASE_ENGINE`` setting needs to be changed to @@ -113,7 +113,7 @@ Your ``DATABASE_ENGINE`` setting needs to be changed to 'ENGINE': 'tenant_schemas.postgresql_backend', # .. } - } + } Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the top of ``MIDDLEWARE_CLASSES``, so that each request can be set to use @@ -125,9 +125,9 @@ the correct schema. 'tenant_schemas.middleware.TenantMiddleware', #... ) - -Add ``tenant_schemas.routers.TenantSyncRouter`` to your `DATABASE_ROUTERS` -setting, so that the correct apps can be synced, depending on what's + +Add ``tenant_schemas.routers.TenantSyncRouter`` to your `DATABASE_ROUTERS` +setting, so that the correct apps can be synced, depending on what's being synced (shared or tenant). .. code-block:: python @@ -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. :: @@ -183,27 +182,31 @@ will automatically create and sync/migrate the schema. tenant.save() Any request made to ``tenant.my-domain.com`` will now automatically set -your PostgreSQL’s ``search_path`` to ``tenant1`` and ``public``, making +your PostgreSQL's ``search_path`` to ``tenant1`` and ``public``, making shared apps available too. This means that any call to the methods ``filter``, ``get``, ``save``, ``delete`` or any other function -involving a database connection will now be done at the tenant’s schema, -so you shouldn’t need to change anything at your views. +involving a database connection will now be done at the tenant's schema, +so you shouldn't need to change anything at your views. -You’re all set, but we have left key details outside of this short +You're all set, but we have left key details outside of this short tutorial, such as creating the public tenant and configuring shared and tenant specific apps. Complete instructions can be found at -`django-tenant-schemas.readthedocs.org`_. +`django-tenant-schemas.readthedocs.io`_. .. _django: https://www.djangoproject.com/ -.. _PostgreSQL schemas: http://www.postgresql.org/docs/9.5/static/ddl-schemas.html -.. _PostgreSQL’s official documentation on schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html +.. _PostgreSQL schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html +.. _PostgreSQL's official documentation on schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html .. _Multi-Tenant Data Architecture: http://msdn.microsoft.com/en-us/library/aa479086.aspx .. |PyPi version| image:: https://img.shields.io/pypi/v/django-tenant-schemas.svg :target: https://pypi.python.org/pypi/django-tenant-schemas .. |PyPi downloads| image:: https://img.shields.io/pypi/dm/django-tenant-schemas.svg :target: https://pypi.python.org/pypi/django-tenant-schemas -.. _setup: https://django-tenant-schemas.readthedocs.org/en/latest/install.html -.. _django-tenant-schemas.readthedocs.org: https://django-tenant-schemas.readthedocs.org/en/latest/ +.. |Python versions| image:: https://img.shields.io/pypi/pyversions/django-tenant-schemas.svg +.. |Travis CI| image:: https://travis-ci.org/bernardopires/django-tenant-schemas.svg?branch=master + :target: https://travis-ci.org/bernardopires/django-tenant-schemas +.. |PostgreSQL| image:: https://img.shields.io/badge/PostgreSQL-9.2%2C%209.3%2C%209.4%2C%209.5%2C%209.6-blue.svg +.. _setup: https://django-tenant-schemas.readthedocs.io/en/latest/install.html +.. _django-tenant-schemas.readthedocs.io: https://django-tenant-schemas.readthedocs.io/en/latest/ diff --git a/changelog b/changelog new file mode 100644 index 0000000..5140116 --- /dev/null +++ b/changelog @@ -0,0 +1,5 @@ +django-tenant-schemas (1.4.8-1) unstable; urgency=low + + * source package automatically created by stdeb 0.8.2 + + -- Jérôme Schneider Tue, 14 Oct 2014 11:39:16 +0200 diff --git a/compat b/compat new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/compat @@ -0,0 +1 @@ +7 diff --git a/control b/control new file mode 100644 index 0000000..96664ed --- /dev/null +++ b/control @@ -0,0 +1,11 @@ +Source: django-tenant-schemas +Maintainer: Jérôme Schneider +Section: python +Priority: optional +Build-Depends: python-setuptools (>= 0.6b3), python-all (>= 2.6.6-3), debhelper (>= 7) +Standards-Version: 3.9.1 + +Package: python-django-tenant-schemas +Architecture: all +Depends: ${misc:Depends}, ${python:Depends} +Description: Tenant support for Django using PostgreSQL schemas. 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 b07c2d4..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'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.autosectionlabel',] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -92,6 +90,11 @@ pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +intersphinx_mapping = { + 'django': ( + 'https://docs.djangoproject.com/en/1.11/', + 'https://docs.djangoproject.com/en/1.11/_objects/'), +} # -- Options for HTML output --------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 5d634d9..e890259 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ Welcome to django-tenant-schemas documentation! =============================================== -This application enables `Django `_ powered websites to have multiple tenants via `PostgreSQL schemas `_. A vital feature for every Software-as-a-Service website. +This application enables `Django `_ powered websites to have multiple tenants via `PostgreSQL schemas `_. A vital feature for every Software-as-a-Service website. Django provides currently no simple way to support multiple tenants using the same project instance, even when only the data is different. Because we don't want you running many copies of your project, you'll be able to have: @@ -10,7 +10,7 @@ Django provides currently no simple way to support multiple tenants using the sa What are schemas? ----------------- -A schema can be seen as a directory in an operating system, each directory (schema) with it's own set of files (tables and objects). This allows the same table name and objects to be used in different schemas without conflict. For an accurate description on schemas, see `PostgreSQL's official documentation on schemas `_. +A schema can be seen as a directory in an operating system, each directory (schema) with it's own set of files (tables and objects). This allows the same table name and objects to be used in different schemas without conflict. For an accurate description on schemas, see `PostgreSQL's official documentation on schemas `_. Why schemas? ------------ @@ -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 ffe586b..ed2b05f 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,6 +1,7 @@ -================== +============ Installation -================== +============ + Assuming you have django installed, the first step is to install ``django-tenant-schemas``. .. code-block:: bash @@ -32,17 +33,47 @@ Add `tenant_schemas.routers.TenantSyncRouter` to your `DATABASE_ROUTERS` setting Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the top of ``MIDDLEWARE_CLASSES``, so that each request can be set to use the correct schema. -If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. +If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. + +If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. + +If you'd like to serve the public tenant for unrecognised hostnames instead, use ``tenant_schemas.middleware.DefaultTenantMiddleware``. To use 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 + + class MyDefaultTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'default' + +.. code-block:: python + MIDDLEWARE_CLASSES = ( 'tenant_schemas.middleware.TenantMiddleware', # 'tenant_schemas.middleware.SuspiciousTenantMiddleware', + # 'tenant_schemas.middleware.DefaultTenantMiddleware', + # 'myproject.middleware.MyDefaultTenantMiddleware', #... ) - -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 + + TEMPLATES = [ + { + 'BACKEND': # ... + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + # ... + 'django.template.context_processors.request', + # ... + ] + } + } + ] .. code-block:: python @@ -50,7 +81,7 @@ Make sure you have ``django.core.context_processors.request`` listed under ``TEM 'django.core.context_processors.request', #... ) - + The Tenant Model ================ Now we have to create your tenant model. Your tenant model can contain whichever fields you want, however, you **must** inherit from ``TenantMixin``. This Mixin only has two fields (``domain_url`` and ``schema_name``) and both are required. Here's an example, suppose we have an app named ``customers`` and we want to create a model called ``Client``. @@ -59,74 +90,82 @@ Now we have to create your tenant model. Your tenant model can contain whichever from django.db import models from tenant_schemas.models import TenantMixin - + class Client(TenantMixin): name = models.CharField(max_length=100) paid_until = models.DateField() on_trial = models.BooleanField() created_on = models.DateField(auto_now_add=True) - + # default true, schema will be automatically created and synced when it is saved - auto_create_schema = True + auto_create_schema = True -Once you have defined your model, don't forget to create the migrations for it or otherwise Django >= 1.9 will not create its table. Replace ``customers`` with your app name. - -.. code-block:: bash - - # Django >= 1.7 - python manage.py makemigrations customers +Before creating the migrations, we must configure a few specific settings. Configure Tenant and Shared Applications ======================================== To make use of shared and tenant-specific applications, there are two settings called ``SHARED_APPS`` and ``TENANT_APPS``. ``SHARED_APPS`` is a tuple of strings just like ``INSTALLED_APPS`` and should contain all apps that you want to be synced to ``public``. If ``SHARED_APPS`` is set, then these are the only apps that will be synced to your ``public`` schema! The same applies for ``TENANT_APPS``, it expects a tuple of strings where each string is an app. If set, only those applications will be synced to all your tenants. Here's a sample setting .. code-block:: python - + SHARED_APPS = ( - 'tenant_schemas', # mandatory + 'tenant_schemas', # mandatory, should always be before any django app 'customers', # you must list the app where your tenant model resides in - + 'django.contrib.contenttypes', - + # everything below here is optional - 'django.contrib.auth', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', ) - + TENANT_APPS = ( - # The following Django contrib apps must be in TENANT_APPS 'django.contrib.contenttypes', # your tenant-specific apps 'myapp.hotels', - 'myapp.houses', + 'myapp.houses', ) - INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] + INSTALLED_APPS = ( + 'tenant_schemas', # mandatory, should always be before any django app + + 'customers', + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'myapp.hotels', + 'myapp.houses', + ) You also have to set where your tenant model is. .. code-block:: python 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 you must create your app migrations for ``customers``: + +.. code-block:: bash + + python manage.py makemigrations customers + +The command ``migrate_schemas --shared`` will create the shared apps on the ``public`` schema. Note: your database should be empty if this is the first time you're running this command. .. code-block:: bash - # Django >= 1.7 python manage.py migrate_schemas --shared - # Django < 1.7 - python manage.py sync_schemas --shared - .. warning:: - Never use ``migrate`` or ``syncdb`` as it would sync *all* your apps to ``public``! - + Never use ``migrate`` as it would sync *all* your apps to ``public``! + Lastly, you need to create a tenant whose schema is ``public`` and it's address is your domain URL. Please see the section on :doc:`use `. You can also specify extra schemas that should be visible to all queries using @@ -145,33 +184,28 @@ globally. available globally. This helps avoid issues caused by hiding the public schema from queries. -South Migrations -================ -If you're on Django 1.6 or older, this app supports `South `_ so if you haven't configured it yet and would like to: +Working with Tenant specific schemas +==================================== +Since each Tenant has it's own schema in the database you need a way to tell Django what +schema to use when using the management commands. -For Django 1.1 or below +A special management command ``tenant_command`` has been added to allow you to +execute Django management commands in the context of a specific Tenant schema. .. code-block:: python - SOUTH_DATABASE_ADAPTER = 'south.db.postgresql_psycopg2' + python manage.py tenant_command loaddata --schema=my_tenant test_fixture -For Django 1.2 or above +.. warning:: -.. code-block:: python + Depending on the configuration of your applications, the command you execute + may impact shared data also. - SOUTH_DATABASE_ADAPTERS = { - 'default': 'south.db.postgresql_psycopg2', - } - -You can list ``south`` under ``TENANT_APPS`` and ``SHARED_APPS`` if you want. +Creating a new Tenant +===================== +See `Creating a new Tenant `_ for more details on how to create a new Tenant in our +application. -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 ================= @@ -179,14 +213,8 @@ Optional Settings .. attribute:: PUBLIC_SCHEMA_NAME :Default: ``'public'`` - - The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. - -.. attribute:: 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. Only relevant if `South `_ is used. + The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. Tenant View-Routing ------------------- @@ -200,12 +228,12 @@ Tenant View-Routing .. code-block:: python PUBLIC_SCHEMA_URLCONF = 'myproject.urls_public' - - When requesting the view ``/login/`` from the public tenant (your main website), it will search for this path on ``PUBLIC_SCHEMA_URLCONF`` instead of ``ROOT_URLCONF``. + + When requesting the view ``/login/`` from the public tenant (your main website), it will search for this path on ``PUBLIC_SCHEMA_URLCONF`` instead of ``ROOT_URLCONF``. Separate projects for the main website and tenants (optional) ------------------------------------------------------------- -In some cases using the ``PUBLIC_SCHEMA_URLCONF`` can be difficult. For example, `Django CMS `_ takes some control over the default Django URL routing by using middlewares that do not play well with the tenants. Another example would be when some apps on the main website need different settings than the tenants website. In these cases it is much simpler if you just run the main website `example.com` as a separate application. +In some cases using the ``PUBLIC_SCHEMA_URLCONF`` can be difficult. For example, `Django CMS `_ takes some control over the default Django URL routing by using middlewares that do not play well with the tenants. Another example would be when some apps on the main website need different settings than the tenants website. In these cases it is much simpler if you just run the main website `example.com` as a separate application. If your projects are ran using a WSGI configuration, this can be done by creating a filed called ``wsgi_main_website.py`` in the same folder as ``wsgi.py``. @@ -256,7 +284,7 @@ Here's how you can configure your Apache server to route all subdomains to your Building Documentation ====================== -Documentation is available in ``docs`` and can be built into a number of +Documentation is available in ``docs`` and can be built into a number of formats using `Sphinx `_. To get started .. code-block:: bash diff --git a/docs/involved.rst b/docs/involved.rst index 84e7755..558659a 100644 --- a/docs/involved.rst +++ b/docs/involved.rst @@ -3,4 +3,4 @@ Get Involved! ============= Suggestions, bugs, ideas, patches, questions -------------------------------------------- -Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub `. :) +Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub `_. :) diff --git a/docs/test.rst b/docs/test.rst index b9b2da6..2f5d8b8 100644 --- a/docs/test.rst +++ b/docs/test.rst @@ -1,14 +1,21 @@ -================== +===== Tests -================== +===== + Running the tests ----------------- -If you're using South, don't forget to set ``SOUTH_TESTS_MIGRATE = False``. Run these tests from the project ``dts_test_project``, it comes prepacked with the correct settings file and extra apps to enable tests to ensure different apps can exist in ``SHARED_APPS`` and ``TENANT_APPS``. +Run these tests from the project ``dts_test_project``, it comes prepacked with the correct settings file and extra apps to enable tests to ensure different apps can exist in ``SHARED_APPS`` and ``TENANT_APPS``. .. code-block:: bash ./manage.py test tenant_schemas.tests +To run the test suite outsite of your application you can use tox_ to test all supported Django versions. + +.. code-block:: bash + + tox + Updating your app's tests to work with tenant-schemas ----------------------------------------------------- Because django will not create tenants for you during your tests, we have packed some custom test cases and other utilities. If you want a test to happen at any of the tenant's domain, you can use the test case ``TenantTestCase``. It will automatically create a tenant for you, set the connection's schema to tenant's schema and make it available at ``self.tenant``. We have also included a ``TenantRequestFactory`` and a ``TenantClient`` so that your requests will all take place at the tenant's domain automatically. Here's an example @@ -21,7 +28,19 @@ Because django will not create tenants for you during your tests, we have packed class BaseSetup(TenantTestCase): def setUp(self): self.c = TenantClient(self.tenant) - + def test_user_profile_view(self): response = self.c.get(reverse('user_profile')) self.assertEqual(response.status_code, 200) + + +Running tests faster +-------------------- +Running tests using ``TenantTestCase`` can start being a bottleneck once the number of tests grow. ``TenantTestCase`` drops, recreates and executes migrations for the test schema every time for every ``TenantTestCase`` you have. If you do not care that the state between tests is kept, an alternative is to use the class ``FastTenantTestCase``. Unlike ``TenantTestCase``, the test schema and its migrations will only be created and ran once. This is a significant improvement in speed coming at the cost of shared state. + +.. code-block:: python + + from tenant_schemas.test.cases import FastTenantTestCase + + +.. _tox: https://tox.readthedocs.io/ diff --git a/docs/use.rst b/docs/use.rst index cc2b2d1..1cab55a 100644 --- a/docs/use.rst +++ b/docs/use.rst @@ -1,9 +1,16 @@ =========================== Using django-tenant-schemas =========================== + +Supported versions +------------------ +You can use ``django-tenant-schemas`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. + +It is necessary to use a PostgreSQL database. ``django-tenant-schemas`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 9.3, the minimum for Django 1.11. + Creating a Tenant ----------------- -Creating a tenant works just like any other model in django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. +Creating a tenant works just like any other model in Django. The first thing we should do is to create the ``public`` tenant to make our main website available. We'll use the previous model we defined for ``Client``. .. code-block:: python @@ -37,7 +44,7 @@ Any call to the methods ``filter``, ``get``, ``save``, ``delete`` or any other f Management commands ------------------- -Every command except tenant_command runs by default on all tenants. You can also create your own commands that run on every tenant by inheriting ``BaseTenantCommand``. +By default, base commands run on the public tenant but you can also own commands that run on a specific tenant by inheriting ``BaseTenantCommand``. For example, if you have the following ``do_foo`` command in the ``foo`` app: @@ -51,7 +58,7 @@ For example, if you have the following ``do_foo`` command in the ``foo`` app: def handle(self, *args, **options): do_foo() -You could create a wrapper command ``tenant_do_foo`` by using ``BaseTenantCommand`` like so: +You could create a wrapper command by using ``BaseTenantCommand``: ``foo/management/commands/tenant_do_foo.py`` @@ -62,16 +69,18 @@ You could create a wrapper command ``tenant_do_foo`` by using ``BaseTenantComman class Command(BaseTenantCommand): COMMAND_NAME = 'do_foo' -To run only a particular schema, there is an optional argument called ``--schema``. +To run the command on a particular schema, there is an optional argument called ``--schema``. .. code-block:: bash - ./manage.py sync_schemas --schema=customer1 + ./manage.py tenant_command do_foo --schema=customer1 -migrate_schemas +If you omit the ``schema`` argument, the interactive shell will ask you to select one. + +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,26 +96,27 @@ The options given to ``migrate_schemas`` are also passed to every ``migrate``. H ./manage.py migrate_schemas --list -sync_schemas -~~~~~~~~~~~~ +``migrate_schemas`` raises an exception when an tenant schema is missing. -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``. +migrate_schemas in parallel +~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. 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 +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 - ./manage.py sync_schemas --migrate + python manage.py migrate_schemas --executor=parallel -You can also use the option ``--tenant`` to only sync tenant apps or ``--shared`` to only sync shared apps. +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``. -.. code-block:: bash +The ``parallel`` executor accepts the following settings: - ./manage.py sync_schemas --shared # will only sync the public schema +* ``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 ~~~~~~~~~~~~~~ @@ -122,15 +132,15 @@ If you don't specify a schema, you will be prompted to enter one. Otherwise, you .. code-block:: bash ./manage.py tenant_command loaddata --schema=customer1 - -createsuperuser + +createsuperuser ~~~~~~~~~~~~~~~ The command ``createsuperuser`` is already automatically wrapped to have a ``schema`` flag. Create a new super user with .. code-block:: bash - ./manage.py createsuperuser --username='admin' --schema=customer1 + ./manage.py createsuperuser --username=admin --schema=customer1 list_tenants @@ -146,6 +156,178 @@ Prints to standard output a tab separated list of schema:domain_url values for e done +Storage +------- + +The :mod:`~django.core.files.storage` API will not isolate media per tenant. Your ``MEDIA_ROOT`` will be a shared space between all tenants. + +To avoid this you should configure a tenant aware storage backend - you will be warned if this is not the case. + +.. code-block:: python + + # settings.py + + MEDIA_ROOT = '/data/media' + MEDIA_URL = '/media/' + DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' + +We provide :class:`tenant_schemas.storage.TenantStorageMixin` which can be added to any third-party storage backend. + +In your reverse proxy configuration you will need to capture use a regular expression to identify the ``domain_url`` to serve content from the appropriate directory. + +.. code-block:: text + + # illustrative /etc/nginx/cond.d/tenant.conf + + upstream web { + server localhost:8080 fail_timeout=5s; + } + + server { + listen 80; + server_name ~^(www\.)?(.+)$; + + location / { + proxy_pass http://web; + proxy_redirect off; + proxy_set_header Host $host; + } + + location /media/ { + alias /data/media/$2/; + } + } + + +Utils +----- + +There are several utils available in `tenant_schemas.utils` that can help you in writing more complicated applications. + + +.. function:: schema_context(schema_name) + +This is a context manager. Database queries performed inside it will be executed in against the passed ``schema_name``. + +.. code-block:: python + + from tenant_schemas.utils import schema_context + + with schema_context(schema_name): + # All comands here are ran under the schema `schema_name` + + # Restores the `SEARCH_PATH` to its original value + + +.. function:: tenant_context(tenant_object) + +This context manager is very similiar to the ``schema_context`` function, +but it takes a tenant model object as the argument instead. + +.. code-block:: python + + from tenant_schemas.utils import tenant_context + + with tenant_context(tenant): + # All commands here are ran under the schema from the `tenant` object + + # Restores the `SEARCH_PATH` to its original value + + + +.. function:: schema_exists(schema_name) + +Returns ``True`` if a schema exists in the current database. + +.. code-block:: python + + from django.core.exceptions import ValidationError + from django.utils.text import slugify + + from tenant_schemas.utils import schema_exists + + class TenantModelForm: + # ... + + def clean_schema_name(self) + schema_name = self.cleaned_data["schema_name"] + schema_name = slugify(schema_name).replace("-", "") + if schema_exists(schema_name): + raise ValidationError("A schema with this name already exists in the database") + else: + return schema_name + + +.. function:: get_tenant_model() + +Returns the class of the tenant model. + +.. function:: get_public_schema_name() + +Returns the name of the public schema (from settings or the default ``public``). + + +.. function:: get_limit_set_calls() + +Returns the ``TENANT_LIMIT_SET_CALLS`` setting or the default (``False``). See below. + + +Signals +------- + +If you want to perform operations after creating a tenant and it's schema is saved and synced, you won't be able to use the built-in ``post_save`` signal, as it sends the signal immediately after the model is saved. + +For this purpose, we have provided a ``post_schema_sync`` signal, which is available in ``tenant_schemas.signals`` + +.. code-block:: python + + + from tenant_schemas.signals import post_schema_sync + from tenant_schemas.models import TenantMixin + + def foo_bar(sender, tenant, **kwargs): + ... + #This function will run after the tenant is saved, its schema created and synced. + ... + + post_schema_sync.connect(foo_bar, sender=TenantMixin) + + +Logging +------- + +The optional ``TenantContextFilter`` can be included in ``settings.LOGGING`` to add the current ``schema_name`` and ``domain_url`` to the logging context. + +.. code-block:: python + + # settings.py + + LOGGING = { + 'filters': { + 'tenant_context': { + '()': 'tenant_schemas.log.TenantContextFilter' + }, + }, + 'formatters': { + 'tenant_context': { + 'format': '[%(schema_name)s:%(domain_url)s] ' + '%(levelname)-7s %(asctime)s %(message)s', + }, + }, + 'handlers': { + 'console': { + 'filters': ['tenant_context'], + }, + }, + } + +This will result in logging output that looks similar to: + +.. code-block:: text + + [example:example.com] DEBUG 13:29 django.db.backends: (0.001) SELECT ... + + Performance Considerations -------------------------- @@ -153,7 +335,8 @@ The hook for ensuring the ``search_path`` is set properly happens inside the ``D .. code-block:: python - #in settings.py: + # settings.py: + TENANT_LIMIT_SET_CALLS = True When set, ``django-tenant-schemas`` will set the search path only once per request. The default is ``False``. diff --git a/dts_test_project/.gitignore b/dts_test_project/.gitignore new file mode 100644 index 0000000..6350e98 --- /dev/null +++ b/dts_test_project/.gitignore @@ -0,0 +1 @@ +.coverage diff --git a/dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py b/dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py new file mode 100644 index 0000000..d8d5f12 --- /dev/null +++ b/dts_test_project/dts_test_app/migrations/0002_test_drop_unique.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dts_test_app', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='DummyModel', + name='unique_value', + field=models.IntegerField(blank=True, null=True, unique=True), + ), + + migrations.AlterField( + model_name='DummyModel', + name='unique_value', + field=models.IntegerField(blank=True, null=True), + ), + + migrations.RemoveField( + model_name='DummyModel', + name='unique_value', + ), + ] diff --git a/dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py b/dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py new file mode 100644 index 0000000..4f3f6fb --- /dev/null +++ b/dts_test_project/dts_test_app/migrations/0003_test_add_db_index.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('dts_test_app', '0002_test_drop_unique'), + ] + + operations = [ + migrations.AddField( + model_name='DummyModel', + name='indexed_value', + field=models.CharField(max_length=255, db_index=True), + ), + ] diff --git a/dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py b/dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py new file mode 100644 index 0000000..5f1828a --- /dev/null +++ b/dts_test_project/dts_test_app/migrations/0004_test_alter_unique.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('dts_test_app', '0003_test_add_db_index'), + ] + + operations = [ + migrations.AlterField( + model_name='DummyModel', + name='indexed_value', + field=models.CharField(max_length=255, unique=True), + ), + + migrations.RemoveField( + model_name='DummyModel', + name='indexed_value', + ), + ] diff --git a/dts_test_project/dts_test_project/settings.py b/dts_test_project/dts_test_project/settings.py index 5bfaec7..9378c67 100644 --- a/dts_test_project/dts_test_project/settings.py +++ b/dts_test_project/dts_test_project/settings.py @@ -2,19 +2,19 @@ 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, ...) 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/ +# 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' @@ -26,6 +26,7 @@ TEMPLATE_DEBUG = True ALLOWED_HOSTS = [] +DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' # Application definition @@ -48,11 +49,16 @@ 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 = ( + 'tenant_schemas', + 'dts_test_app', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +) MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', @@ -67,18 +73,17 @@ ROOT_URLCONF = 'dts_test_project.urls' 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': { 'ENGINE': 'tenant_schemas.postgresql_backend', - 'NAME': 'dts_test_project', - 'USER': 'postgres', - 'PASSWORD': 'root', - 'HOST': 'localhost', - 'PORT': '', + 'NAME': os.environ.get('PG_NAME', 'dts_test_project'), + 'USER': os.environ.get('PG_USER'), + 'PASSWORD': os.environ.get('PG_PASSWORD'), + 'HOST': os.environ.get('PG_HOST'), + 'PORT': int(os.environ.get('PG_PORT')) if os.environ.get('PG_PORT') else None, } } @@ -104,8 +109,18 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.contrib.messages.context_processors.messages', ) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'context_processors': TEMPLATE_CONTEXT_PROCESSORS + }, + } +] + # Internationalization -# https://docs.djangoproject.com/en/1.6/topics/i18n/ +# https://docs.djangoproject.com/en/1.8/topics/i18n/ LANGUAGE_CODE = 'en-us' @@ -117,8 +132,48 @@ USE_L10N = True 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/' + +STATICFILES_STORAGE = 'tenant_schemas.storage.TenantStaticFilesStorage' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + }, + 'tenant_context': { + '()': 'tenant_schemas.log.TenantContextFilter' + }, + }, + 'formatters': { + 'simple': { + 'format': '%(levelname)-7s %(asctime)s %(message)s', + }, + 'tenant_context': { + 'format': '[%(schema_name)s:%(domain_url)s] ' + '%(levelname)-7s %(asctime)s %(message)s', + }, + }, + 'handlers': { + 'null': { + 'class': 'logging.NullHandler', + }, + 'console': { + 'class': 'logging.StreamHandler', + 'filters': ['tenant_context'], + 'formatter': 'tenant_context', + }, + }, + 'loggers': { + '': { + 'handlers': ['null'], + 'level': 'DEBUG', + 'propagate': True, + }, + }, +} diff --git a/dts_test_project/dts_test_project/urls.py b/dts_test_project/dts_test_project/urls.py index a000786..637600f 100644 --- a/dts_test_project/dts_test_project/urls.py +++ b/dts_test_project/dts_test_project/urls.py @@ -1,21 +1 @@ -"""wtf URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.9/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Add an import: from blog import urls as blog_urls - 2. Import the include() function: from django.conf.urls import url, include - 3. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) -""" -from django.conf.urls import url -from django.contrib import admin - -urlpatterns = [ -] +urlpatterns = [] diff --git a/examples/tenant_tutorial/templates/base.html b/examples/tenant_tutorial/templates/base.html index 3b77cd2..fe0373a 100644 --- a/examples/tenant_tutorial/templates/base.html +++ b/examples/tenant_tutorial/templates/base.html @@ -52,7 +52,7 @@ {{ tenant.name }} · {% endfor %}

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

diff --git a/examples/tenant_tutorial/templates/index_public.html b/examples/tenant_tutorial/templates/index_public.html index 3b4a0aa..2f39a72 100644 --- a/examples/tenant_tutorial/templates/index_public.html +++ b/examples/tenant_tutorial/templates/index_public.html @@ -18,11 +18,7 @@

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

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

When you're done refresh this page.

{% elif no_public_tenant %}

Second Step: Create a public tenant

@@ -124,9 +120,8 @@ Client(domain_url='tenant2.trendy-sass.com',

Where to go from here

There are some interesting features that we did not cover.

{% endif %} {% endblock %} diff --git a/examples/tenant_tutorial/tenant_tutorial/settings.py b/examples/tenant_tutorial/tenant_tutorial/settings.py index f7ab9da..0f4001c 100644 --- a/examples/tenant_tutorial/tenant_tutorial/settings.py +++ b/examples/tenant_tutorial/tenant_tutorial/settings.py @@ -1,7 +1,9 @@ +import os +from collections import OrderedDict + # Django settings for tenant_tutorial project. DEBUG = True -TEMPLATE_DEBUG = DEBUG ADMINS = ( # ('Your Name', 'your_email@example.com'), @@ -22,7 +24,7 @@ DATABASES = { # Hosts/domain names that are valid for this site; required if DEBUG is False # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['localhost', '.trendy-sass.com'] # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name @@ -83,11 +85,6 @@ STATICFILES_FINDERS = ( # Make this unique, and don't share it with anybody. SECRET_KEY = 'as-%*_93v=r5*p_7cu8-%o6b&x^g+q$#*e*fl)k)x0-t=%q0qa' -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -) DATABASE_ROUTERS = ( 'tenant_schemas.routers.TenantSyncRouter', @@ -106,23 +103,37 @@ MIDDLEWARE_CLASSES = ( # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) -TEMPLATE_CONTEXT_PROCESSORS = ( - 'django.core.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.core.context_processors.debug', - 'django.core.context_processors.media', - 'django.core.context_processors.static', - 'django.contrib.messages.context_processors.messages', -) - ROOT_URLCONF = 'tenant_tutorial.urls_tenants' PUBLIC_SCHEMA_URLCONF = 'tenant_tutorial.urls_public' # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'tenant_tutorial.wsgi.application' -import os -TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), '..', 'templates').replace('\\', '/'),) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.dirname(__file__), '..', 'templates').replace('\\', '/'), + ], + 'APP_DIRS': False, + 'OPTIONS': { + 'debug': DEBUG, + 'context_processors': [ + # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this + #~ 'django.core.context_processors.request', + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + # List of callables that know how to import templates from various sources. + 'loaders': [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', # insert your TEMPLATE_LOADERS here + ] + }, + }, +] SHARED_APPS = ( 'tenant_schemas', # mandatory @@ -143,11 +154,17 @@ 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',) +DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' + +INSTALLED_APPS = ( + 'tenant_schemas', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +) SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' diff --git a/examples/tenant_tutorial/tenant_tutorial/urls_public.py b/examples/tenant_tutorial/tenant_tutorial/urls_public.py index 5a0b6b2..d359891 100644 --- a/examples/tenant_tutorial/tenant_tutorial/urls_public.py +++ b/examples/tenant_tutorial/tenant_tutorial/urls_public.py @@ -1,6 +1,7 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from tenant_tutorial.views import HomeView -urlpatterns = patterns('', - url(r'^$', HomeView.as_view()), - ) + +urlpatterns = [ + url(r'^$', HomeView.as_view()), +] diff --git a/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py b/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py index a113b93..f37dff5 100644 --- a/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py +++ b/examples/tenant_tutorial/tenant_tutorial/urls_tenants.py @@ -1,6 +1,6 @@ -from django.conf.urls import patterns, url from customers.views import TenantView +from django.conf.urls import url -urlpatterns = patterns('', - url(r'^$', TenantView.as_view()), - ) +urlpatterns = [ + url(r'^$', TenantView.as_view()), +] diff --git a/examples/tenant_tutorial/tenant_tutorial/views.py b/examples/tenant_tutorial/tenant_tutorial/views.py index 8fe6826..451f317 100644 --- a/examples/tenant_tutorial/tenant_tutorial/views.py +++ b/examples/tenant_tutorial/tenant_tutorial/views.py @@ -1,9 +1,9 @@ -import django +from customers.models import Client from django.conf import settings from django.db import utils from django.views.generic import TemplateView + from tenant_schemas.utils import remove_www -from customers.models import Client class HomeView(TemplateView): @@ -14,9 +14,6 @@ class HomeView(TemplateView): hostname_without_port = remove_www(self.request.get_host().split(':')[0]) - if django.VERSION >= (1, 7, 0): - context['DJANGO17'] = True - try: Client.objects.get(schema_name='public') except utils.DatabaseError: diff --git a/examples/tenant_tutorial/tenant_tutorial/wsgi.py b/examples/tenant_tutorial/tenant_tutorial/wsgi.py index c326bd8..32a9fb2 100644 --- a/examples/tenant_tutorial/tenant_tutorial/wsgi.py +++ b/examples/tenant_tutorial/tenant_tutorial/wsgi.py @@ -14,6 +14,7 @@ framework. """ import os +from django.core.wsgi import get_wsgi_application # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks # if running multiple sites in the same mod_wsgi process. To fix this, use @@ -24,7 +25,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tenant_tutorial.settings") # This application object is used by any WSGI server configured to use this # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. -from django.core.wsgi import get_wsgi_application application = get_wsgi_application() # Apply WSGI middleware here. diff --git a/files b/files new file mode 100644 index 0000000..7a45648 --- /dev/null +++ b/files @@ -0,0 +1,2 @@ +python-django-tenant-schemas_1.4.8-10-ga5f144a-1_all.deb python optional +python3-django-tenant-schemas_1.4.8-10-ga5f144a-1_all.deb python optional diff --git a/python-django-tenant-schemas.postinst.debhelper b/python-django-tenant-schemas.postinst.debhelper new file mode 100644 index 0000000..79168ea --- /dev/null +++ b/python-django-tenant-schemas.postinst.debhelper @@ -0,0 +1,7 @@ + +# Automatically added by dh_python2: +if which pycompile >/dev/null 2>&1; then + pycompile -p python-django-tenant-schemas +fi + +# End automatically added section diff --git a/python-django-tenant-schemas.prerm.debhelper b/python-django-tenant-schemas.prerm.debhelper new file mode 100644 index 0000000..5575e82 --- /dev/null +++ b/python-django-tenant-schemas.prerm.debhelper @@ -0,0 +1,12 @@ + +# Automatically added by dh_python2: +if which pyclean >/dev/null 2>&1; then + pyclean -p python-django-tenant-schemas +else + dpkg -L python-django-tenant-schemas | grep \.py$ | while read file + do + rm -f "${file}"[co] >/dev/null + done +fi + +# End automatically added section diff --git a/python-django-tenant-schemas.substvars b/python-django-tenant-schemas.substvars new file mode 100644 index 0000000..606bc03 --- /dev/null +++ b/python-django-tenant-schemas.substvars @@ -0,0 +1,4 @@ +python:Versions=2.7 +python:Provides=python2.7-django-tenant-schemas +python:Depends=python (>= 2.7), python (<< 2.8), python:any (>= 2.6.6-7~), python-django, python-psycopg2 +misc:Depends= diff --git a/python3-django-tenant-schemas.postinst.debhelper b/python3-django-tenant-schemas.postinst.debhelper new file mode 100644 index 0000000..bff23bb --- /dev/null +++ b/python3-django-tenant-schemas.postinst.debhelper @@ -0,0 +1,7 @@ + +# Automatically added by dhpython: +if which py3compile >/dev/null 2>&1; then + py3compile -p python3-django-tenant-schemas +fi + +# End automatically added section diff --git a/python3-django-tenant-schemas.prerm.debhelper b/python3-django-tenant-schemas.prerm.debhelper new file mode 100644 index 0000000..91bb179 --- /dev/null +++ b/python3-django-tenant-schemas.prerm.debhelper @@ -0,0 +1,10 @@ + +# Automatically added by dhpython: +if which py3clean >/dev/null 2>&1; then + py3clean -p python3-django-tenant-schemas +else + dpkg -L python3-django-tenant-schemas | perl -ne 's,/([^/]*)\.py$,/__pycache__/\1.*, or next; unlink $_ or die $! foreach glob($_)' + find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir +fi + +# End automatically added section diff --git a/python3-django-tenant-schemas.substvars b/python3-django-tenant-schemas.substvars new file mode 100644 index 0000000..1cf1595 --- /dev/null +++ b/python3-django-tenant-schemas.substvars @@ -0,0 +1,2 @@ +python3:Depends=python3-psycopg2, python3-django, python3:any (>= 3.3.2-2~) +misc:Depends= diff --git a/rules b/rules new file mode 100755 index 0000000..b2e2109 --- /dev/null +++ b/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f + +# This file was automatically generated by stdeb 0.8.2 at +# Tue, 14 Oct 2014 11:39:16 +0200 + +%: + dh $@ --with python2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..eafaa65 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[bumpversion] +current_version = 1.9.0 +commit = True +tag = True + +[flake8] +exclude = .tox,docs,build,migrations,__init__.py +ignore = C901,E501,E731 + diff --git a/setup.py b/setup.py index ba6f4b0..4afa103 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,6 @@ try: except ImportError: from distutils.core import setup - def get_version(): '''Use the VERSION, if absent generates a version with git describe, if not tag exists, take 0.0.0- and add the length of the commit log. @@ -36,10 +35,10 @@ 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', - 'tenant_schemas.management.commands.legacy', 'tenant_schemas.templatetags', 'tenant_schemas.test', 'tenant_schemas.tests', @@ -52,10 +51,17 @@ setup( classifiers=[ 'License :: OSI Approved :: MIT License', 'Framework :: Django', + 'Framework :: Django :: 1.8', + 'Framework :: Django :: 1.9', + 'Framework :: Django :: 1.10', 'Programming Language :: Python', + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Database", + "Topic :: Software Development :: Libraries", ], install_requires=[ - 'Django >= 1.6.0', + 'Django >= 1.8.0', 'psycopg2', ], zip_safe=False, diff --git a/source/format b/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/source/options b/source/options new file mode 100644 index 0000000..bcc4bbb --- /dev/null +++ b/source/options @@ -0,0 +1 @@ +extend-diff-ignore="\.egg-info$" \ No newline at end of file diff --git a/tenant_schemas/__init__.py b/tenant_schemas/__init__.py index 6898c66..7aa0d8a 100644 --- a/tenant_schemas/__init__.py +++ b/tenant_schemas/__init__.py @@ -1,45 +1 @@ -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 - - -recommended_config = """ -Warning: You should put 'tenant_schemas' at the end of INSTALLED_APPS: -INSTALLED_APPS = TENANT_APPS + SHARED_APPS + ('tenant_schemas',) -This is necessary to overwrite built-in django management commands with -their schema-aware implementations. -""" -# Test for configuration recommendations. These are best practices, -# they avoid hard to find bugs and unexpected behaviour. -if not hasattr(settings, 'TENANT_APPS'): - raise ImproperlyConfigured('TENANT_APPS setting not set') - -if not settings.TENANT_APPS: - raise ImproperlyConfigured("TENANT_APPS is empty. " - "Maybe you don't need this app?") - -if not hasattr(settings, 'TENANT_MODEL'): - raise ImproperlyConfigured('TENANT_MODEL setting not set') - -if 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()) - - # make sure no tenant schema is in settings.PG_EXTRA_SEARCH_PATHS - invalid_schemas = set(settings.PG_EXTRA_SEARCH_PATHS).intersection( - get_tenant_model().objects.all().values_list('schema_name', flat=True)) - if invalid_schemas: - raise ImproperlyConfigured( - "Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS." - % list(invalid_schemas)) +default_app_config = 'tenant_schemas.apps.TenantSchemaConfig' diff --git a/tenant_schemas/apps.py b/tenant_schemas/apps.py new file mode 100644 index 0000000..6b16b62 --- /dev/null +++ b/tenant_schemas/apps.py @@ -0,0 +1,104 @@ +from django.apps import AppConfig, apps +from django.conf import settings +from django.core.checks import Critical, Error, Warning, register +from django.core.files.storage import default_storage +from tenant_schemas.storage import TenantStorageMixin +from tenant_schemas.utils import get_public_schema_name, get_tenant_model + + +class TenantSchemaConfig(AppConfig): + name = 'tenant_schemas' + + +@register('config') +def best_practice(app_configs, **kwargs): + """ + Test for configuration recommendations. These are best practices, they + avoid hard to find bugs and unexpected behaviour. + """ + if app_configs is None: + app_configs = apps.get_app_configs() + + # Take the app_configs and turn them into *old style* application names. + # This is what we expect in the SHARED_APPS and TENANT_APPS settings. + INSTALLED_APPS = [ + config.name + for config in app_configs + ] + + if not hasattr(settings, 'TENANT_APPS'): + return [Critical('TENANT_APPS setting not set')] + + if not hasattr(settings, 'TENANT_MODEL'): + return [Critical('TENANT_MODEL setting not set')] + + if not hasattr(settings, 'SHARED_APPS'): + return [Critical('SHARED_APPS setting not set')] + + if 'tenant_schemas.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS: + return [ + Critical("DATABASE_ROUTERS setting must contain " + "'tenant_schemas.routers.TenantSyncRouter'.") + ] + + errors = [] + + django_index = next(i for i, s in enumerate(INSTALLED_APPS) if s.startswith('django.')) + if INSTALLED_APPS.index('tenant_schemas') > django_index: + errors.append( + Warning("You should put 'tenant_schemas' before any django " + "core applications in INSTALLED_APPS.", + obj="django.conf.settings", + hint="This is necessary to overwrite built-in django " + "management commands with their schema-aware " + "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?", + id="tenant_schemas.E001")) + + if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): + if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: + errors.append(Critical( + "%s can not be included on PG_EXTRA_SEARCH_PATHS." + % get_public_schema_name())) + + # make sure no tenant schema is in settings.PG_EXTRA_SEARCH_PATHS + invalid_schemas = set(settings.PG_EXTRA_SEARCH_PATHS).intersection( + get_tenant_model().objects.all().values_list('schema_name', flat=True)) + if invalid_schemas: + errors.append(Critical( + "Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS." + % ", ".join(sorted(invalid_schemas)))) + + if not settings.SHARED_APPS: + errors.append( + Warning("SHARED_APPS is empty.", + 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], + 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], + 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/log.py b/tenant_schemas/log.py new file mode 100644 index 0000000..9a4e6b5 --- /dev/null +++ b/tenant_schemas/log.py @@ -0,0 +1,15 @@ +import logging + +from django.db import connection + + +class TenantContextFilter(logging.Filter): + """ + Add the current ``schema_name`` and ``domain_url`` to log records. + + Thanks to @regolith for the snippet on #248 + """ + def filter(self, record): + record.schema_name = connection.tenant.schema_name + record.domain_url = getattr(connection.tenant, 'domain_url', '') + return True diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index 4542d03..c3c28ed 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -1,9 +1,8 @@ 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 @@ -28,30 +27,30 @@ class BaseTenantCommand(BaseCommand): app_name = get_commands()[obj.COMMAND_NAME] if isinstance(app_name, BaseCommand): # If the command is already loaded, use it directly. - cmdclass = app_name + obj._original_command = app_name else: - cmdclass = load_command_class(app_name, obj.COMMAND_NAME) + obj._original_command = load_command_class(app_name, obj.COMMAND_NAME) - 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 {cmd} for all registered schemata. You can use regular " + "{cmd} options.\n\nOriginal help for {cmd}:\n\n{help}".format( + cmd=obj.COMMAND_NAME, + help=getattr(obj._original_command, 'help', 'none'), ) + ) - # prepend the command's original help with the info about schemata iteration - obj.help = "Calls %s for all registered schemata. You can use regular %s options. " \ - "Original help for %s: %s" % (obj.COMMAND_NAME, obj.COMMAND_NAME, obj.COMMAND_NAME, - getattr(cmdclass, 'help', 'none')) return obj def add_arguments(self, parser): super(BaseTenantCommand, self).add_arguments(parser) parser.add_argument("-s", "--schema", dest="schema_name") - parser.add_argument("-p", "--skip-public", dest="skip_public", action="store_true", default=False) + parser.add_argument("-p", "--skip-public", dest="skip_public", + action="store_true", default=False) + # use the privately held reference to the underlying command to invoke + # the add_arguments path on this parser instance + self._original_command.add_arguments(parser) def execute_command(self, tenant, command_name, *args, **options): verbosity = int(options.get('verbosity')) @@ -83,12 +82,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") @@ -100,7 +93,7 @@ class InteractiveTenantOption(object): if not all_tenants: raise CommandError("""There are no tenants in the system. To learn how create a tenant, see: -https://django-tenant-schemas.readthedocs.org/en/latest/use.html#creating-a-tenant""") +https://django-tenant-schemas.readthedocs.io/en/latest/use.html#creating-a-tenant""") if options.get('schema_name'): tenant_schema = options['schema_name'] @@ -128,7 +121,8 @@ class TenantWrappedCommand(InteractiveTenantOption, BaseCommand): def __new__(cls, *args, **kwargs): obj = super(TenantWrappedCommand, cls).__new__(cls, *args, **kwargs) obj.command_instance = obj.COMMAND() - obj.option_list = obj.command_instance.option_list + if django.VERSION <= (1,10,0): + obj.option_list = obj.command_instance.option_list return obj def add_arguments(self, parser): @@ -143,28 +137,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, @@ -175,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/collectstatic_schemas.py b/tenant_schemas/management/commands/collectstatic_schemas.py new file mode 100644 index 0000000..c20d435 --- /dev/null +++ b/tenant_schemas/management/commands/collectstatic_schemas.py @@ -0,0 +1,5 @@ +from tenant_schemas.management.commands import BaseTenantCommand + + +class Command(BaseTenantCommand): + COMMAND_NAME = 'collectstatic' diff --git a/tenant_schemas/management/commands/legacy/__init__.py b/tenant_schemas/management/commands/legacy/__init__.py deleted file mode 100644 index 40eb513..0000000 --- a/tenant_schemas/management/commands/legacy/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Commands for Django <=1.6 (before south was integrated to Django). -""" diff --git a/tenant_schemas/management/commands/legacy/migrate_schemas.py b/tenant_schemas/management/commands/legacy/migrate_schemas.py deleted file mode 100644 index 5af69c5..0000000 --- a/tenant_schemas/management/commands/legacy/migrate_schemas.py +++ /dev/null @@ -1,88 +0,0 @@ -from django.conf import settings -from django.db import connection -from south import migration -from south.migration.base import Migrations -from south.management.commands.migrate import Command as MigrateCommand -from tenant_schemas.management.commands import SyncCommon -from tenant_schemas.utils import get_tenant_model, get_public_schema_name - - -class Command(SyncCommon): - help = "Migrate schemas with South" - option_list = MigrateCommand.option_list + SyncCommon.option_list - - def handle(self, *args, **options): - super(Command, self).handle(*args, **options) - - if self.sync_public: - self.migrate_public_apps() - if self.sync_tenant: - self.migrate_tenant_apps(self.schema_name) - - def _set_managed_apps(self, included_apps, excluded_apps): - """ while sync_schemas works by setting which apps are managed, on south we set which apps should be ignored """ - ignored_apps = [] - if excluded_apps: - for item in excluded_apps: - if item not in included_apps: - ignored_apps.append(item) - - for app in ignored_apps: - app_label = app.split('.')[-1] - settings.SOUTH_MIGRATION_MODULES[app_label] = 'ignore' - - self._clear_south_cache() - - def _save_south_settings(self): - self._old_south_modules = None - if hasattr(settings, "SOUTH_MIGRATION_MODULES") and settings.SOUTH_MIGRATION_MODULES is not None: - self._old_south_modules = settings.SOUTH_MIGRATION_MODULES.copy() - else: - settings.SOUTH_MIGRATION_MODULES = dict() - - def _restore_south_settings(self): - settings.SOUTH_MIGRATION_MODULES = self._old_south_modules - - def _clear_south_cache(self): - for mig in list(migration.all_migrations()): - delattr(mig._application, "migrations") - Migrations._clear_cache() - - def _migrate_schema(self, tenant): - connection.set_tenant(tenant, include_public=True) - MigrateCommand().execute(*self.args, **self.options) - - def migrate_tenant_apps(self, schema_name=None): - self._save_south_settings() - - apps = self.tenant_apps or self.installed_apps - self._set_managed_apps(included_apps=apps, excluded_apps=self.shared_apps) - - if schema_name: - self._notice("=== Running migrate for schema: %s" % schema_name) - connection.set_schema_to_public() - tenant = get_tenant_model().objects.get(schema_name=schema_name) - self._migrate_schema(tenant) - else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) - if not all_tenants: - self._notice("No tenants found") - - for tenant in all_tenants: - Migrations._dependencies_done = False # very important, the dependencies need to be purged from cache - self._notice("=== Running migrate for schema %s" % tenant.schema_name) - self._migrate_schema(tenant) - - self._restore_south_settings() - - def migrate_public_apps(self): - self._save_south_settings() - - apps = self.shared_apps or self.installed_apps - self._set_managed_apps(included_apps=apps, excluded_apps=self.tenant_apps) - - self._notice("=== Running migrate for schema public") - MigrateCommand().execute(*self.args, **self.options) - - self._clear_south_cache() - self._restore_south_settings() diff --git a/tenant_schemas/management/commands/migrate.py b/tenant_schemas/management/commands/migrate.py index 5a8deec..25ad908 100644 --- a/tenant_schemas/management/commands/migrate.py +++ b/tenant_schemas/management/commands/migrate.py @@ -1,26 +1,20 @@ -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: - from south.management.commands.migrate import Command as MigrateCommand -except ImportError: - MigrateCommand = BaseCommand - -class Command(MigrateCommand): +class Command(BaseCommand): def handle(self, *args, **options): database = options.get('database', 'default') - if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' or - MigrateCommand is BaseCommand): + if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend'): raise CommandError("migrate has been disabled, for database '{0}'. Use migrate_schemas " "instead. Please read the documentation if you don't know why you " "shouldn't call migrate directly!".format(database)) super(Command, self).handle(*args, **options) -if django.VERSION >= (1, 7, 0) and django_is_in_test_mode(): - from .migrate_schemas import MigrateSchemasCommand +if django_is_in_test_mode(): Command = MigrateSchemasCommand diff --git a/tenant_schemas/management/commands/migrate_schemas.py b/tenant_schemas/management/commands/migrate_schemas.py index 3178600..7e131c2 100644 --- a/tenant_schemas/management/commands/migrate_schemas.py +++ b/tenant_schemas/management/commands/migrate_schemas.py @@ -1,68 +1,53 @@ 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.core.management.commands.migrate import Command as MigrateCommand from django.db import connection -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.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 +else: + class MigrationSchemaMissing(django.db.utils.DatabaseError): + pass -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): """ 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__() + if django.VERSION <= (1, 10, 0): + self.option_list += MigrateCommand.option_list + super(Command, self).__init__(stdout, stderr, no_color) def add_arguments(self, parser): - 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() + 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 RuntimeError('Schema "{}" does not exist'.format( + raise MigrationSchemaMissing('Schema "{}" does not exist'.format( self.schema_name)) else: - self.run_migrations(self.schema_name, settings.TENANT_APPS) + 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) - 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)) - - -if django.VERSION >= (1, 7, 0): - Command = MigrateSchemasCommand -else: - from .legacy.migrate_schemas import Command + 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/management/commands/sync_schemas.py b/tenant_schemas/management/commands/sync_schemas.py deleted file mode 100644 index c324f6e..0000000 --- a/tenant_schemas/management/commands/sync_schemas.py +++ /dev/null @@ -1,57 +0,0 @@ -import django -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ImproperlyConfigured -from django.db.models import get_apps, get_models -if "south" in settings.INSTALLED_APPS: - from south.management.commands.syncdb import Command as SyncdbCommand -else: - from django.core.management.commands.syncdb import Command as SyncdbCommand -from django.db import connection -from tenant_schemas.utils import get_tenant_model, get_public_schema_name -from tenant_schemas.management.commands import SyncCommon - - -class Command(SyncCommon): - help = "Sync schemas based on TENANT_APPS and SHARED_APPS settings" - option_list = SyncdbCommand.option_list + SyncCommon.option_list - - def handle(self, *args, **options): - if django.VERSION >= (1, 7, 0): - raise RuntimeError('This command is only meant to be used for 1.6' - ' and older version of django. For 1.7, use' - ' `migrate_schemas` instead.') - super(Command, self).handle(*args, **options) - - if "south" in settings.INSTALLED_APPS: - self.options["migrate"] = False - - # Content types may be different on tenants, so reset the cache - ContentType.objects.clear_cache() - - if self.sync_public: - self.sync_public_apps() - if self.sync_tenant: - self.sync_tenant_apps(self.schema_name) - - def _sync_tenant(self, tenant): - if int(self.options.get('verbosity', 1)) >= 1: - self._notice("=== Running syncdb for schema: %s" % tenant.schema_name) - connection.set_tenant(tenant, include_public=False) - SyncdbCommand().execute(**self.options) - - def sync_tenant_apps(self, schema_name=None): - if schema_name: - tenant = get_tenant_model().objects.filter(schema_name=schema_name).get() - self._sync_tenant(tenant) - else: - all_tenants = get_tenant_model().objects.exclude(schema_name=get_public_schema_name()) - if not all_tenants: - self._notice("No tenants found!") - - for tenant in all_tenants: - self._sync_tenant(tenant) - - def sync_public_apps(self): - SyncdbCommand().execute(**self.options) - self._notice("=== Running syncdb for schema public") diff --git a/tenant_schemas/management/commands/syncdb.py b/tenant_schemas/management/commands/syncdb.py deleted file mode 100644 index 9683e57..0000000 --- a/tenant_schemas/management/commands/syncdb.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.core.management.base import CommandError -from django.conf import settings -from tenant_schemas.utils import django_is_in_test_mode - -if 'south' in settings.INSTALLED_APPS: - from south.management.commands import syncdb -else: - from django.core.management.commands import syncdb - - -class Command(syncdb.Command): - - def handle(self, *args, **options): - database = options.get('database', 'default') - if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' and not - django_is_in_test_mode()): - raise CommandError("syncdb has been disabled, for database '{0}'. " - "Use sync_schemas instead. Please read the " - "documentation if you don't know why " - "you shouldn't call syncdb directly!".format(database)) - super(Command, self).handle(*args, **options) diff --git a/tenant_schemas/middleware.py b/tenant_schemas/middleware.py index cb19ea6..c70c053 100644 --- a/tenant_schemas/middleware.py +++ b/tenant_schemas/middleware.py @@ -1,3 +1,5 @@ +import django + from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import DisallowedHost @@ -6,48 +8,72 @@ from django.http import Http404 from tenant_schemas.utils import (get_tenant_model, remove_www, get_public_schema_name) +if django.VERSION >= (1, 10, 0): + MIDDLEWARE_MIXIN = django.utils.deprecation.MiddlewareMixin +else: + MIDDLEWARE_MIXIN = object -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. - """ +""" +These middlewares should be placed at the very top of the middleware stack. +Selects the proper database schema using request information. Can fail in +various ways which is better than corrupting or revealing data. + +Extend BaseTenantMiddleware for a custom tenant selection strategy, +such as inspecting the header, or extracting it from some OAuth token. +""" + +class BaseTenantMiddleware(MIDDLEWARE_MIXIN): 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]) + return remove_www(request.get_host().split(':')[0]).lower() def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. connection.set_schema_to_public() - hostname = self.hostname_from_request(request) + hostname = self.hostname_from_request(request) TenantModel = get_tenant_model() + try: - request.tenant = TenantModel.objects.get(domain_url=hostname) - 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)) - # 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() + request.tenant = tenant + connection.set_tenant(request.tenant) # 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 +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): """ @@ -60,3 +86,29 @@ class SuspiciousTenantMiddleware(TenantMiddleware): discussion on this middleware. """ TENANT_NOT_FOUND_EXCEPTION = DisallowedHost + + +class DefaultTenantMiddleware(SuspiciousTenantMiddleware): + """ + Extend the SuspiciousTenantMiddleware in scenario where you want to + configure a tenant to be served if the hostname does not match any of the + existing tenants. + + Subclass and override DEFAULT_SCHEMA_NAME to use a schema other than the + public schema. + + class MyTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'default' + """ + DEFAULT_SCHEMA_NAME = None + + def get_tenant(self, model, hostname, request): + try: + return super(DefaultTenantMiddleware, self).get_tenant( + model, hostname, request) + except model.DoesNotExist: + schema_name = self.DEFAULT_SCHEMA_NAME + if not schema_name: + schema_name = get_public_schema_name() + + return model.objects.get(schema_name=schema_name) 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 1781051..d51aa34 100644 --- a/tenant_schemas/models.py +++ b/tenant_schemas/models.py @@ -1,12 +1,30 @@ -import django -from django.conf import settings -from django.db import models, connection from django.core.management import call_command +from django.db import connection, models from tenant_schemas.postgresql_backend.base import _check_schema_name from tenant_schemas.signals import post_schema_sync -from tenant_schemas.utils import django_is_in_test_mode, schema_exists -from tenant_schemas.utils import get_public_schema_name +from tenant_schemas.utils import get_public_schema_name, schema_exists + + +class 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): @@ -30,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 @@ -50,12 +69,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): """ @@ -69,9 +89,9 @@ class TenantMixin(models.Model): if schema_exists(self.schema_name) and (self.auto_drop_schema or force_drop): cursor = connection.cursor() - cursor.execute('DROP SCHEMA %s CASCADE' % self.schema_name) + cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE' % self.schema_name) - super(TenantMixin, self).delete(*args, **kwargs) + return super(TenantMixin, self).delete(*args, **kwargs) def create_schema(self, check_if_exists=False, sync_schema=True, verbosity=1): @@ -92,27 +112,9 @@ class TenantMixin(models.Model): cursor.execute('CREATE SCHEMA %s' % self.schema_name) if sync_schema: - if django.VERSION >= (1, 7, 0,): - call_command('migrate_schemas', - schema_name=self.schema_name, - interactive=False, - verbosity=verbosity) - else: - # default is faking all migrations and syncing directly to the current models state - fake_all_migrations = getattr(settings, 'TENANT_CREATION_FAKES_MIGRATIONS', True) - call_command('sync_schemas', - schema_name=self.schema_name, - tenant=True, - public=False, - interactive=False, - migrate_all=fake_all_migrations, - verbosity=verbosity) - - # run/fake all migrations - if 'south' in settings.INSTALLED_APPS and not django_is_in_test_mode(): - call_command('migrate_schemas', - fake=fake_all_migrations, - schema_name=self.schema_name, - verbosity=verbosity) + call_command('migrate_schemas', + schema_name=self.schema_name, + interactive=False, + verbosity=verbosity) connection.set_schema_to_public() diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index c444c9a..2bc836d 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -1,24 +1,20 @@ import re import warnings +import psycopg2 import threading from django.conf import settings -try: - # Django versions >= 1.9 - from django.utils.module_loading import import_module -except ImportError: - # Django versions < 1.9 - from django.utils.importlib import import_module -from django.core.exceptions import ImproperlyConfigured, ValidationError from django.contrib.contenttypes.models import ContentType, ContentTypeManager +from django.core.exceptions import ImproperlyConfigured, ValidationError +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 -import django.db.utils -import psycopg2 + ORIGINAL_BACKEND = getattr(settings, 'ORIGINAL_BACKEND', 'django.db.backends.postgresql_psycopg2') - -original_backend = import_module(ORIGINAL_BACKEND + '.base') +# Django 1.9+ takes care to rename the default backend to 'django.db.backends.postgresql' +original_backend = django.db.utils.load_backend(ORIGINAL_BACKEND) EXTRA_SEARCH_PATHS = getattr(settings, 'PG_EXTRA_SEARCH_PATHS', []) @@ -73,12 +69,8 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): Main API method to current database schema, but it does not actually modify the db connection. """ + self.set_schema(tenant.schema_name, include_public) self.tenant = tenant - self.schema_name = tenant.schema_name - self.include_public_schema = include_public - self.set_settings_schema(self.schema_name) - self.search_path_set = False - ContentType.objects.clear_cache() def set_schema(self, schema_name, include_public=True): """ @@ -90,17 +82,20 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): self.include_public_schema = include_public self.set_settings_schema(schema_name) self.search_path_set = False + # Content type can no longer be cached as public and tenant schemas + # have different models. If someone wants to change this, the cache + # needs to be separated between public and shared schemas. If this + # cache isn't cleared, this can cause permission problems. For example, + # on public, a particular model has id 14, but on the tenants it has + # the id 15. if 14 is cached instead of 15, the permissions for the + # wrong model will be fetched. ContentType.objects.clear_cache() def set_schema_to_public(self): """ Instructs to stay in the common 'public' schema. """ - self.tenant = FakeTenant(schema_name=get_public_schema_name()) - self.schema_name = get_public_schema_name() - self.set_settings_schema(self.schema_name) - self.search_path_set = False - ContentType.objects.clear_cache() + self.set_schema(get_public_schema_name()) def set_settings_schema(self, schema_name): self.settings_dict['SCHEMA'] = schema_name @@ -115,12 +110,16 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): category=DeprecationWarning) return self.tenant - def _cursor(self): + def _cursor(self, name=None): """ Here it happens. We hope every Django db operation using PostgreSQL must go through this to get the cursor handle. We change the path. """ - cursor = super(DatabaseWrapper, self)._cursor() + if name: + # Only supported and required by Django 1.11 (server-side cursor) + cursor = super(DatabaseWrapper, self)._cursor(name=name) + else: + cursor = super(DatabaseWrapper, self)._cursor() # optionally limit the number of executions - under load, the execution # of `set search_path` can be quite time consuming @@ -143,16 +142,28 @@ class DatabaseWrapper(original_backend.DatabaseWrapper): search_paths = [self.schema_name] search_paths.extend(EXTRA_SEARCH_PATHS) + + if name: + # Named cursor can only be used once + cursor_for_search_path = self.connection.cursor() + else: + # Reuse + cursor_for_search_path = cursor + # In the event that an error already happened in this transaction and we are going # to rollback we should just ignore database error when setting the search_path # if the next instruction is not a rollback it will just fail also, so # we do not have to worry that it's not the good one try: - cursor.execute('SET search_path = {0}'.format(','.join(search_paths))) + cursor_for_search_path.execute('SET search_path = {0}'.format(','.join(search_paths))) except (django.db.utils.DatabaseError, psycopg2.InternalError): self.search_path_set = False else: self.search_path_set = True + + if name: + cursor_for_search_path.close() + return cursor @@ -164,12 +175,6 @@ class FakeTenant: def __init__(self, schema_name): self.schema_name = schema_name -if ORIGINAL_BACKEND == "django.contrib.gis.db.backends.postgis": - DatabaseError = django.db.utils.DatabaseError - IntegrityError = psycopg2.IntegrityError -else: - DatabaseError = original_backend.DatabaseError - IntegrityError = original_backend.IntegrityError # Make the ContentType cache tenant and thread safe ContentTypeManager._thread_local_cache = threading.local() diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 8ca09e7..7ff2cca 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -1,158 +1,80 @@ -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 __future__ import unicode_literals + +from collections import namedtuple + +from django.db.backends.base.introspection import ( + BaseDatabaseIntrospection, FieldInfo, TableInfo, +) +try: + # Django >= 1.11 + from django.db.models.indexes import Index +except ImportError: + Index = None +from django.utils.encoding import force_text + +fields = FieldInfo._fields +if 'default' not in fields: + fields += ('default',) + +FieldInfo = namedtuple('FieldInfo', fields) -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, 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] +class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): + # Maps type codes to Django Field types. + data_types_reverse = { + 16: 'BooleanField', + 17: 'BinaryField', + 20: 'BigIntegerField', + 21: 'SmallIntegerField', + 23: 'IntegerField', + 25: 'TextField', + 700: 'FloatField', + 701: 'FloatField', + 869: 'GenericIPAddressField', + 1042: 'CharField', # blank-padded + 1043: 'CharField', + 1082: 'DateField', + 1083: 'TimeField', + 1114: 'DateTimeField', + 1184: 'DateTimeField', + 1266: 'TimeField', + 1700: 'DecimalField', + 2950: 'UUIDField', + } - def 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(""" - 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 = %s AND - kc.table_name = %s - ORDER BY kc.ordinal_position ASC - """, [self.connection.schema_name, 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(""" - 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 = %s AND - kc.table_name = %s - """, [self.connection.schema_name, 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(""" - 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 c.relname = %s - AND c.relnamespace = n.oid - AND n.nspname = %s - """, [table_name, self.connection.schema_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 + 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(""" - SELECT column_name, is_nullable - FROM information_schema.columns - WHERE table_schema = %s AND table_name = %s""", [self.connection.schema_name, table_name]) - null_map = dict(cursor.fetchall()) - cursor.execute("SELECT * FROM %s LIMIT 1" % self.connection.ops.quote_name(table_name)) - return [FieldInfo(*((force_text(line[0]),) + line[1:6] + (null_map[force_text(line[0])] == 'YES',))) - for line in cursor.description] + _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; + """ - def get_relations(self, cursor, table_name): - """ - Returns a dictionary of {field_index: (field_index_other_table, other_table)} - representing all relationships to the given table. Indexes are 0-based. - """ - cursor.execute(""" - SELECT con.conkey, con.confkey, c2.relname - FROM pg_catalog.pg_constraint con, pg_catalog.pg_class c1, pg_catalog.pg_class c2, pg_catalog.pg_namespace n - WHERE c1.oid = con.conrelid - AND c2.oid = con.confrelid - AND c1.relnamespace = n.oid - AND c2.relnamespace = n.oid - AND con.relnamespace = n.oid - AND n.nspname = '%s' - AND c1.relname = %s - AND con.contype = 'f'""", [self.connection.schema_name, table_name]) - relations = {} - for row in cursor.fetchall(): - # row[0] and row[1] are single-item lists, so grab the single item. - relations[row[0][0] - 1] = (row[1][0] - 1, row[2]) - return relations + _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 + """ - def get_key_columns(self, cursor, table_name): - key_columns = [] - cursor.execute(""" - SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column - FROM information_schema.constraint_column_usage ccu + _get_relations_query = """ + SELECT c2.relname, a1.attname, a2.attname + FROM pg_constraint con + LEFT JOIN pg_class c1 ON con.conrelid = c1.oid + LEFT JOIN pg_class c2 ON con.confrelid = c2.oid + LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] + LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] + LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = con.connamespace + 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 @@ -161,24 +83,171 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 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 = %s AND tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s""", [table_name, self.connectin.schema_name]) - key_columns.extend(cursor.fetchall()) - return key_columns + 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 + WHERE c.oid = idx.indrelid + AND idx.indexrelid = c2.oid + AND attr.attrelid = c.oid + AND attr.attnum = idx.indkey[0] + AND c.relname = %(table)s + AND n.nspname = %(schema)s + """ + + _get_constraints_query = """ + SELECT + c.conname, + array( + SELECT attname + FROM ( + SELECT unnest(c.conkey) AS colid, + generate_series(1, array_length(c.conkey, 1)) AS arridx + ) AS cols + JOIN pg_attribute AS ca ON cols.colid = ca.attnum + WHERE ca.attrelid = c.conrelid + ORDER BY cols.arridx + ), + c.contype, + (SELECT fkc.relname || '.' || fka.attname + FROM pg_attribute AS fka + JOIN pg_class AS fkc ON fka.attrelid = fkc.oid + WHERE fka.attrelid = c.confrelid + AND fka.attnum = c.confkey[1]), + cl.reloptions + FROM pg_constraint AS c + JOIN pg_class AS cl ON c.conrelid = cl.oid + JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid + WHERE ns.nspname = %(schema)s AND cl.relname = %(table)s + """ + + _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 + indexname, array_agg(attname ORDER BY rnum), indisunique, indisprimary, + array_agg(ordering ORDER BY rnum), amname, exprdef, s2.attoptions + FROM ( + SELECT + row_number() OVER () as rnum, c2.relname as indexname, + idx.*, attr.attname, am.amname, + CASE + WHEN idx.indexprs IS NOT NULL THEN + pg_get_indexdef(idx.indexrelid) + END AS exprdef, + CASE am.amname + WHEN 'btree' THEN + CASE (option & 1) + WHEN 1 THEN 'DESC' ELSE 'ASC' + END + END as ordering, + c2.reloptions as attoptions + FROM ( + SELECT + *, unnest(i.indkey) as key, unnest(i.indoption) as option + FROM pg_index i + ) idx + LEFT JOIN pg_class c ON idx.indrelid = c.oid + LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid + LEFT JOIN pg_am am ON c2.relam = am.oid + LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key + LEFT JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.relname = %(table)s + AND n.nspname = %(schema)s + ) s2 + GROUP BY indexname, indisunique, indisprimary, amname, exprdef, attoptions; + """ + + def get_field_type(self, data_type, description): + field_type = super(DatabaseSchemaIntrospection, self).get_field_type(data_type, description) + if description.default and 'nextval' in description.default: + if field_type == 'IntegerField': + return 'AutoField' + elif field_type == 'BigIntegerField': + return 'BigAutoField' + return field_type + + def get_table_list(self, cursor): + """ + Returns a list of table and view names in the current schema. + """ + cursor.execute(self._get_table_list_query, { + 'schema': self.connection.schema_name + }) + + return [ + TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) + for row in cursor.fetchall() + if row[0] not in self.ignored_tables + ] + + def get_table_description(self, cursor, table_name): + """ + Returns a description of the table, with the DB-API cursor.description interface. + """ + + # 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 LIMIT 1' % self.connection.ops.quote_name(table_name)) + + return [ + FieldInfo(*( + (force_text(line[0]),) + + line[1:6] + + (field_map[force_text(line[0])][0] == 'YES', field_map[force_text(line[0])][1]) + )) for line in cursor.description + ] + + 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(""" - 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 n.nspname = %s - AND c.relname = %s""", [self.connection.schema_name, table_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 @@ -195,3 +264,54 @@ class DatabaseSchemaIntrospection(DatabaseIntrospection): 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. Also retrieve the definition of expression-based + indexes. + """ + constraints = {} + # Loop over the key table, collecting things as constraints. The column + # array must return column names in the same order in which they were + # created + # The subquery containing generate_series can be replaced with + # "WITH ORDINALITY" when support for PostgreSQL 9.3 is dropped. + cursor.execute(self._get_constraints_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + for constraint, columns, kind, used_cols, options in cursor.fetchall(): + constraints[constraint] = { + "columns": columns, + "primary_key": kind == "p", + "unique": kind in ["p", "u"], + "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, + "check": kind == "c", + "index": False, + "definition": None, + "options": options, + } + + # Now get indexes + cursor.execute(self._get_index_constraints_query, { + 'schema': self.connection.schema_name, + 'table': table_name, + }) + + for index, columns, unique, primary, orders, type_, definition, options in cursor.fetchall(): + if index not in constraints: + constraints[index] = { + "columns": columns if columns != [None] else [], + "orders": orders if orders != [None] else [], + "primary_key": primary, + "unique": unique, + "foreign_key": None, + "check": False, + "index": True, + "type": Index.suffix if type_ == 'btree' and Index else type_, + "definition": definition, + "options": options, + } + return constraints diff --git a/tenant_schemas/routers.py b/tenant_schemas/routers.py index ec484e2..5702e81 100644 --- a/tenant_schemas/routers.py +++ b/tenant_schemas/routers.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db.models.base import ModelBase +from django.db.utils import load_backend class TenantSyncRouter(object): @@ -13,6 +14,12 @@ class TenantSyncRouter(object): # https://code.djangoproject.com/ticket/20704 from django.db import connection from tenant_schemas.utils import get_public_schema_name, app_labels + from tenant_schemas.postgresql_backend.base import DatabaseWrapper as TenantDbWrapper + + db_engine = settings.DATABASES[db]['ENGINE'] + if not (db_engine == 'tenant_schemas.postgresql_backend' or + issubclass(getattr(load_backend(db_engine), 'DatabaseWrapper'), TenantDbWrapper)): + return None if isinstance(app_label, ModelBase): # In django <1.7 the `app_label` parameter is actually `model` diff --git a/tenant_schemas/storage.py b/tenant_schemas/storage.py new file mode 100644 index 0000000..ed01fea --- /dev/null +++ b/tenant_schemas/storage.py @@ -0,0 +1,55 @@ +import os + +from django.core.exceptions import SuspiciousOperation +from django.utils._os import safe_join + +from django.db import connection + +from django.core.files.storage import FileSystemStorage +from django.contrib.staticfiles.storage import StaticFilesStorage + +__all__ = ( + 'TenantStorageMixin', + 'TenantFileSystemStorage', + 'TenantStaticFilesStorage', +) + + +class TenantStorageMixin(object): + """ + Mixin that can be combined with other Storage backends to colocate media + for all tenants in distinct subdirectories. + + Using rewriting rules at the reverse proxy we can determine which content + gets served up, while any code interactions will account for the multiple + tenancy of the project. + """ + def path(self, name): + """ + Look for files in subdirectory of MEDIA_ROOT using the tenant's + domain_url value as the specifier. + """ + if name is None: + name = '' + try: + location = safe_join(self.location, connection.tenant.domain_url) + except AttributeError: + location = self.location + try: + path = safe_join(location, name) + except ValueError: + raise SuspiciousOperation( + "Attempted access to '%s' denied." % name) + return os.path.normpath(path) + + +class TenantFileSystemStorage(TenantStorageMixin, FileSystemStorage): + """ + Implementation that extends core Django's FileSystemStorage. + """ + + +class TenantStaticFilesStorage(TenantStorageMixin, StaticFilesStorage): + """ + Implementation that extends core Django's StaticFilesStorage. + """ diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index dd8c45b..6224a26 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -4,83 +4,106 @@ 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.utils.encoding import force_bytes -from django.utils._os import safe_join from django.db import connection +from django.template import TemplateDoesNotExist +from django.template.base import Template +from django.template.loaders.base import Loader as BaseLoader +from django.utils._os import safe_join +from django.utils.encoding import force_bytes + from tenant_schemas.postgresql_backend.base import FakeTenant +try: + from django.template import Origin + + def make_origin(engine, name, loader, template_name, dirs): + return Origin(name=name, template_name=template_name, loader=loader) + +except ImportError: # Django 1.8 backwards compatibility + def make_origin(engine, name, loader, template_name, dirs): + return engine.make_origin(name, loader, template_name, dirs) + 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 = make_origin(self.engine, 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 @@ -114,7 +137,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: @@ -122,4 +145,5 @@ class FilesystemLoader(BaseLoader): else: error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." raise TemplateDoesNotExist(error_msg) + load_template_source.is_usable = True diff --git a/tenant_schemas/test/cases.py b/tenant_schemas/test/cases.py index 1d0dbcd..beef8dd 100644 --- a/tenant_schemas/test/cases.py +++ b/tenant_schemas/test/cases.py @@ -1,16 +1,28 @@ -import django +from django.conf import settings from django.core.management import call_command from django.db import connection from django.test import TestCase +from tenant_schemas.utils import get_public_schema_name, get_tenant_model -from tenant_schemas.utils import get_tenant_model -from tenant_schemas.utils import get_public_schema_name +ALLOWED_TEST_DOMAIN = '.test.com' class TenantTestCase(TestCase): + @classmethod + def add_allowed_test_domain(cls): + # ALLOWED_HOSTS is a special setting of Django setup_test_environment so we can't modify it with helpers + if ALLOWED_TEST_DOMAIN not in settings.ALLOWED_HOSTS: + settings.ALLOWED_HOSTS += [ALLOWED_TEST_DOMAIN] + + @classmethod + def remove_allowed_test_domain(cls): + if ALLOWED_TEST_DOMAIN in settings.ALLOWED_HOSTS: + settings.ALLOWED_HOSTS.remove(ALLOWED_TEST_DOMAIN) + @classmethod def setUpClass(cls): cls.sync_shared() + cls.add_allowed_test_domain() tenant_domain = 'tenant.test.com' cls.tenant = get_tenant_model()(domain_url=tenant_domain, schema_name='test') cls.tenant.save(verbosity=0) # todo: is there any way to get the verbosity from the test command here? @@ -22,22 +34,35 @@ class TenantTestCase(TestCase): connection.set_schema_to_public() cls.tenant.delete() + cls.remove_allowed_test_domain() cursor = connection.cursor() - cursor.execute('DROP SCHEMA test CASCADE') + cursor.execute('DROP SCHEMA IF EXISTS 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, - ) + call_command('migrate_schemas', + schema_name=get_public_schema_name(), + interactive=False, + verbosity=0) + + +class FastTenantTestCase(TenantTestCase): + @classmethod + def setUpClass(cls): + cls.sync_shared() + cls.add_allowed_test_domain() + tenant_domain = 'tenant.test.com' + + TenantModel = get_tenant_model() + try: + cls.tenant = TenantModel.objects.get(domain_url=tenant_domain, schema_name='test') + except: + cls.tenant = TenantModel(domain_url=tenant_domain, schema_name='test') + cls.tenant.save(verbosity=0) + + connection.set_tenant(cls.tenant) + + @classmethod + def tearDownClass(cls): + connection.set_schema_to_public() + cls.remove_allowed_test_domain() diff --git a/tenant_schemas/tests/__init__.py b/tenant_schemas/tests/__init__.py index 99a2470..2b6c0f2 100644 --- a/tenant_schemas/tests/__init__.py +++ b/tenant_schemas/tests/__init__.py @@ -1,3 +1,6 @@ -from test_routes import * -from test_tenants import * -from test_cache import * +from .template_loader import * +from .test_cache import * +from .test_log import * +from .test_routes import * +from .test_tenants import * +from .test_utils import * diff --git a/tenant_schemas/tests/models.py b/tenant_schemas/tests/models.py index da1ba7b..054fe8a 100644 --- a/tenant_schemas/tests/models.py +++ b/tenant_schemas/tests/models.py @@ -1,4 +1,3 @@ -from django.db import models from tenant_schemas.models import TenantMixin diff --git a/tenant_schemas/tests/template_loader/__init__.py b/tenant_schemas/tests/template_loader/__init__.py new file mode 100644 index 0000000..ac17d9d --- /dev/null +++ b/tenant_schemas/tests/template_loader/__init__.py @@ -0,0 +1 @@ +from .test_cached_template_loader import CachedLoaderTests diff --git a/tenant_schemas/tests/template_loader/templates/hello.html b/tenant_schemas/tests/template_loader/templates/hello.html new file mode 100755 index 0000000..7f54a62 --- /dev/null +++ b/tenant_schemas/tests/template_loader/templates/hello.html @@ -0,0 +1 @@ +Hello! (Django templates) diff --git a/tenant_schemas/tests/template_loader/test_cached_template_loader.py b/tenant_schemas/tests/template_loader/test_cached_template_loader.py new file mode 100755 index 0000000..39facfe --- /dev/null +++ b/tenant_schemas/tests/template_loader/test_cached_template_loader.py @@ -0,0 +1,31 @@ +import os + +from django.template.loader import get_template +from django.test import SimpleTestCase, override_settings + + +@override_settings( + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.dirname(__file__), "templates") + ], + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + ], + 'loaders': [ + ('tenant_schemas.template_loaders.CachedLoader', ( + 'tenant_schemas.template_loaders.FilesystemLoader', + 'django.template.loaders.filesystem.Loader' + )) + ] + }, + } + ] +) +class CachedLoaderTests(SimpleTestCase): + def test_get_template(self): + template = get_template("hello.html") + self.assertEqual(template.render(), "Hello! (Django templates)\n") diff --git a/tenant_schemas/tests/test_apps.py b/tenant_schemas/tests/test_apps.py new file mode 100644 index 0000000..e0df917 --- /dev/null +++ b/tenant_schemas/tests/test_apps.py @@ -0,0 +1,136 @@ +from django.apps import apps +from django.core.checks import Critical, Error, Warning +from django.test import TestCase +from django.test.utils import override_settings + +from tenant_schemas.apps import best_practice +from tenant_schemas.utils import get_tenant_model + + +class AppConfigTests(TestCase): + + maxDiff = None + + def assertBestPractice(self, expected): + actual = best_practice(apps.get_app_configs()) + self.assertEqual(expected, actual) + + @override_settings() + def test_unset_tenant_apps(self): + from django.conf import settings + del settings.TENANT_APPS + self.assertBestPractice([ + Critical('TENANT_APPS setting not set'), + ]) + + @override_settings() + def test_unset_tenant_model(self): + from django.conf import settings + del settings.TENANT_MODEL + self.assertBestPractice([ + Critical('TENANT_MODEL setting not set'), + ]) + + @override_settings() + def test_unset_shared_apps(self): + from django.conf import settings + del settings.SHARED_APPS + self.assertBestPractice([ + Critical('SHARED_APPS setting not set'), + ]) + + @override_settings(DATABASE_ROUTERS=()) + def test_database_routers(self): + self.assertBestPractice([ + Critical("DATABASE_ROUTERS setting must contain " + "'tenant_schemas.routers.TenantSyncRouter'."), + ]) + + @override_settings(INSTALLED_APPS=[ + 'dts_test_app', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'tenant_schemas', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + ]) + def test_tenant_schemas_before_django_installed_apps(self): + self.assertBestPractice([ + Warning("You should put 'tenant_schemas' before any django " + "core applications in INSTALLED_APPS.", + obj="django.conf.settings", + hint="This is necessary to overwrite built-in django " + "management commands with their schema-aware " + "implementations.", + id="tenant_schemas.W001"), + ]) + + @override_settings(INSTALLED_APPS=[ + 'dts_test_app', + 'customers', + 'tenant_schemas', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + ]) + def test_tenant_schemas_after_custom_apps_in_installed_apps(self): + self.assertBestPractice([]) + + @override_settings(TENANT_APPS=()) + def test_tenant_apps_empty(self): + self.assertBestPractice([ + Error("TENANT_APPS is empty.", + hint="Maybe you don't need this app?", + id="tenant_schemas.E001"), + ]) + + @override_settings(PG_EXTRA_SEARCH_PATHS=['public', 'demo1', 'demo2']) + def test_public_schema_on_extra_search_paths(self): + TenantModel = get_tenant_model() + TenantModel.objects.create( + schema_name='demo1', domain_url='demo1.example.com') + TenantModel.objects.create( + schema_name='demo2', domain_url='demo2.example.com') + self.assertBestPractice([ + Critical("public can not be included on PG_EXTRA_SEARCH_PATHS."), + Critical("Do not include tenant schemas (demo1, demo2) on PG_EXTRA_SEARCH_PATHS."), + ]) + + @override_settings(SHARED_APPS=()) + def test_shared_apps_empty(self): + self.assertBestPractice([ + Warning("SHARED_APPS is empty.", + id="tenant_schemas.W002"), + ]) + + @override_settings(TENANT_APPS=( + 'dts_test_app', + 'django.contrib.flatpages', + )) + def test_tenant_app_missing_from_install_apps(self): + self.assertBestPractice([ + Error("You have TENANT_APPS that are not in INSTALLED_APPS", + hint=['django.contrib.flatpages'], + id="tenant_schemas.E002"), + ]) + + @override_settings(SHARED_APPS=( + 'tenant_schemas', + 'customers', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.flatpages', + 'django.contrib.messages', + 'django.contrib.sessions', + 'django.contrib.staticfiles', + )) + def test_shared_app_missing_from_install_apps(self): + self.assertBestPractice([ + Error("You have SHARED_APPS that are not in INSTALLED_APPS", + hint=['django.contrib.flatpages'], + id="tenant_schemas.E003"), + ]) diff --git a/tenant_schemas/tests/test_log.py b/tenant_schemas/tests/test_log.py new file mode 100644 index 0000000..4dd27dd --- /dev/null +++ b/tenant_schemas/tests/test_log.py @@ -0,0 +1,28 @@ +import logging +from mock import patch + +from django.test import TestCase + +from tenant_schemas import log + + +@patch('tenant_schemas.log.connection.tenant', autospec=True, + schema_name='context') +class LoggingFilterTests(TestCase): + + def test_tenant_context_filter(self, mock_connection): + mock_connection.domain_url = 'context.example.com' + filter_ = log.TenantContextFilter() + record = logging.makeLogRecord({}) + res = filter_.filter(record) + self.assertEqual(res, True) + self.assertEqual(record.schema_name, 'context') + self.assertEqual(record.domain_url, 'context.example.com') + + def test_tenant_context_filter_blank_domain_url(self, mock_connection): + filter_ = log.TenantContextFilter() + record = logging.makeLogRecord({}) + res = filter_.filter(record) + self.assertEqual(res, True) + self.assertEqual(record.schema_name, 'context') + self.assertEqual(record.domain_url, '') diff --git a/tenant_schemas/tests/test_routes.py b/tenant_schemas/tests/test_routes.py index 0af0afd..b847813 100644 --- a/tenant_schemas/tests/test_routes.py +++ b/tenant_schemas/tests/test_routes.py @@ -1,10 +1,19 @@ from django.conf import settings +from django.core.exceptions import DisallowedHost +from django.http import Http404 from django.test.client import RequestFactory -from tenant_schemas import get_public_schema_name - -from tenant_schemas.middleware import TenantMiddleware +from tenant_schemas.middleware import DefaultTenantMiddleware, TenantMiddleware from tenant_schemas.tests.models import Tenant from tenant_schemas.tests.testcases import BaseTestCase +from tenant_schemas.utils import get_public_schema_name + + +class TestDefaultTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'test' + + +class MissingDefaultTenantMiddleware(DefaultTenantMiddleware): + DEFAULT_SCHEMA_NAME = 'missing' class RoutesTestCase(BaseTestCase): @@ -24,35 +33,57 @@ class RoutesTestCase(BaseTestCase): super(RoutesTestCase, self).setUp() self.factory = RequestFactory() self.tm = TenantMiddleware() + self.dtm = DefaultTenantMiddleware() self.tenant_domain = 'tenant.test.com' self.tenant = Tenant(domain_url=self.tenant_domain, schema_name='test') self.tenant.save(verbosity=BaseTestCase.get_verbosity()) + self.non_existent_domain = 'no-tenant.test.com' + self.non_existent_tenant = Tenant(domain_url=self.non_existent_domain, schema_name='no-tenant') + + self.url = '/any/path/' + def test_tenant_routing(self): - """ - Request path should not be altered. - """ - request_url = '/any/request/' - request = self.factory.get('/any/request/', - HTTP_HOST=self.tenant_domain) + request = self.factory.get( + self.url, HTTP_HOST=self.tenant_domain) self.tm.process_request(request) - - self.assertEquals(request.path_info, request_url) - - # request.tenant should also have been set + self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.tenant) def test_public_schema_routing(self): - """ - Request path should not be altered. - """ - request_url = '/any/request/' - request = self.factory.get('/any/request/', - HTTP_HOST=self.public_tenant.domain_url) + request = self.factory.get( + self.url, HTTP_HOST=self.public_tenant.domain_url) self.tm.process_request(request) - - self.assertEquals(request.path_info, request_url) - - # request.tenant should also have been set + self.assertEquals(request.path_info, self.url) self.assertEquals(request.tenant, self.public_tenant) + + def test_non_existent_tenant_routing(self): + """Raise 404 for unrecognised hostnames.""" + request = self.factory.get( + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) + self.assertRaises(Http404, self.tm.process_request, request) + + def test_non_existent_tenant_to_default_schema_routing(self): + """Route unrecognised hostnames to the 'public' tenant.""" + request = self.factory.get( + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) + self.dtm.process_request(request) + self.assertEquals(request.path_info, self.url) + self.assertEquals(request.tenant, self.public_tenant) + + def test_non_existent_tenant_custom_middleware(self): + """Route unrecognised hostnames to the 'test' tenant.""" + dtm = TestDefaultTenantMiddleware() + request = self.factory.get( + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) + dtm.process_request(request) + self.assertEquals(request.path_info, self.url) + self.assertEquals(request.tenant, self.tenant) + + def test_non_existent_tenant_and_default_custom_middleware(self): + """Route unrecognised hostnames to the 'missing' tenant.""" + dtm = MissingDefaultTenantMiddleware() + request = self.factory.get( + self.url, HTTP_HOST=self.non_existent_tenant.domain_url) + self.assertRaises(DisallowedHost, dtm.process_request, request) diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 8ea7c82..2bf26d8 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -1,12 +1,17 @@ -import django import json +try: + # python 2 + from StringIO import StringIO +except ImportError: + # python 3 + from io import StringIO + from django.conf import settings from django.contrib.auth.models import User 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 @@ -45,11 +50,9 @@ class TenantDataAndSettingsTest(BaseTestCase): 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') + schema_name='non_auto_sync_tenant') tenant.save(verbosity=BaseTestCase.get_verbosity()) - self.assertFalse(schema_exists(tenant.schema_name)) def test_sync_tenant(self): @@ -74,7 +77,56 @@ class TenantDataAndSettingsTest(BaseTestCase): connection.set_tenant(tenant) # test if data is still there - self.assertEquals(DummyModel.objects.count(), 2) + self.assertEqual(DummyModel.objects.count(), 2) + + def test_auto_drop_schema(self): + """ + When deleting a tenant with auto_drop_schema=True, it should delete + the schema associated with the tenant. + """ + self.assertFalse(schema_exists('auto_drop_tenant')) + Tenant.auto_drop_schema = True + tenant = Tenant(domain_url='something.test.com', + schema_name='auto_drop_tenant') + tenant.save(verbosity=BaseTestCase.get_verbosity()) + self.assertTrue(schema_exists(tenant.schema_name)) + cursor = connection.cursor() + + # Force pending trigger events to be executed + cursor.execute('SET CONSTRAINTS ALL IMMEDIATE') + + tenant.delete() + self.assertFalse(schema_exists(tenant.schema_name)) + 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', @@ -124,7 +176,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,9 +269,12 @@ class TenantCommandTest(BaseTestCase): Tenant(domain_url='localhost', schema_name='public').save(verbosity=BaseTestCase.get_verbosity()) out = StringIO() - call_command('tenant_command', 'dumpdata', 'tenant_schemas', natural_foreign=True, - schema_name=get_public_schema_name(), stdout=out) - self.assertItemsEqual( + call_command('tenant_command', + args=('dumpdata', 'tenant_schemas'), + natural_foreign=True, + schema_name=get_public_schema_name(), + stdout=out) + self.assertEqual( json.loads('[{"fields": {"domain_url": "localhost", "schema_name": "public"}, ' '"model": "tenant_schemas.tenant", "pk": 1}]'), json.loads(out.getvalue())) diff --git a/tenant_schemas/tests/test_utils.py b/tenant_schemas/tests/test_utils.py new file mode 100644 index 0000000..a740240 --- /dev/null +++ b/tenant_schemas/tests/test_utils.py @@ -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'], + ) diff --git a/tenant_schemas/tests/testcases.py b/tenant_schemas/tests/testcases.py index 29280d5..964c76f 100644 --- a/tenant_schemas/tests/testcases.py +++ b/tenant_schemas/tests/testcases.py @@ -1,5 +1,5 @@ -import django import inspect + from django.conf import settings from django.core.management import call_command from django.db import connection @@ -21,15 +21,24 @@ class BaseTestCase(TestCase): 'django.contrib.contenttypes', 'django.contrib.auth', ) settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS + if '.test.com' not in settings.ALLOWED_HOSTS: + settings.ALLOWED_HOSTS += ['.test.com'] # Django calls syncdb by default for the test database, but we want # a blank public schema for this set of tests. 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(), )) + cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE; CREATE SCHEMA %s;' + % (get_public_schema_name(), get_public_schema_name())) super(BaseTestCase, cls).setUpClass() + @classmethod + def tearDownClass(cls): + super(BaseTestCase, cls).tearDownClass() + + if '.test.com' in settings.ALLOWED_HOSTS: + settings.ALLOWED_HOSTS.remove('.test.com') + def setUp(self): connection.set_schema_to_public() super(BaseTestCase, self).setUp() @@ -39,7 +48,7 @@ class BaseTestCase(TestCase): for s in reversed(inspect.stack()): options = s[0].f_locals.get('options') if isinstance(options, dict): - return int(options['verbosity']) + return int(options['verbosity']) - 2 return 1 @classmethod @@ -52,17 +61,8 @@ class BaseTestCase(TestCase): @classmethod def sync_shared(cls): - if django.VERSION >= (1, 7, 0): - call_command('migrate_schemas', - schema_name=get_public_schema_name(), - interactive=False, - verbosity=cls.get_verbosity(), - run_syncdb=True) - else: - call_command('sync_schemas', - schema_name=get_public_schema_name(), - tenant=False, - public=True, - interactive=False, - migrate_all=True, - verbosity=cls.get_verbosity()) + call_command('migrate_schemas', + schema_name=get_public_schema_name(), + interactive=False, + verbosity=cls.get_verbosity(), + run_syncdb=True) diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index 15c0598..0351a8a 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -1,5 +1,3 @@ -import warnings -from django.conf import settings from django.core.urlresolvers import reverse as reverse_default from django.utils.functional import lazy from tenant_schemas.utils import clean_tenant_url @@ -7,7 +5,13 @@ from tenant_schemas.utils import clean_tenant_url def reverse(viewname, urlconf=None, args=None, kwargs=None, prefix=None, current_app=None): - url = reverse_default(viewname, urlconf, args, kwargs, prefix, current_app) + url = reverse_default( + viewname=viewname, + urlconf=urlconf, + args=args, + kwargs=kwargs, + current_app=current_app + ) return clean_tenant_url(url) reverse_lazy = lazy(reverse, str) diff --git a/tenant_schemas/utils.py b/tenant_schemas/utils.py index 11a7ac9..338ebc6 100644 --- a/tenant_schemas/utils.py +++ b/tenant_schemas/utils.py @@ -1,11 +1,14 @@ from contextlib import contextmanager + from django.conf import settings from django.db import connection + try: - from django.apps import apps + from django.apps import apps, AppConfig get_model = apps.get_model except ImportError: from django.db.models.loading import get_model + AppConfig = None from django.core import mail @@ -52,8 +55,8 @@ def clean_tenant_url(url_string): Removes the TENANT_TOKEN from a particular string """ if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF'): - if (settings.PUBLIC_SCHEMA_URLCONF - and url_string.startswith(settings.PUBLIC_SCHEMA_URLCONF)): + if (settings.PUBLIC_SCHEMA_URLCONF and + url_string.startswith(settings.PUBLIC_SCHEMA_URLCONF)): url_string = url_string[len(settings.PUBLIC_SCHEMA_URLCONF):] return url_string @@ -105,6 +108,11 @@ 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] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..29ee264 --- /dev/null +++ b/tox.ini @@ -0,0 +1,31 @@ +[tox] +envlist = py{27,35}-dj{18,110,111}-{standard,parallel} + +[travis:env] +DJANGO = + 1.8: dj18-{standard,parallel} + 1.10: dj110-{standard,parallel} + 1.11: dj111-{standard,parallel} + +[testenv] +usedevelop = True + +deps = + coverage + mock + tblib + dj18: Django~=1.8.0 + dj110: Django~=1.10.0 + dj111: Django~=1.11.0 + +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/* diff --git a/version.py b/version.py index c1e513f..9a235c5 100644 --- a/version.py +++ b/version.py @@ -30,11 +30,10 @@ # contains the following line: # # include VERSION +from subprocess import Popen, PIPE __all__ = ("get_git_version") -from subprocess import Popen, PIPE - def call_git_describe(): try: