Merge branch 'wip/18178-new-sync'

This commit is contained in:
Frédéric Péters 2018-08-13 07:29:59 +02:00
commit 154fd2c9bc
76 changed files with 2107 additions and 899 deletions

20
.editorconfig Normal file
View File

@ -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

27
.travis.yml Normal file
View File

@ -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

View File

@ -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 dont want you running many copies of your project, youll 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 its 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
`PostgreSQLs 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 its 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 its up and down sides, for a more in-depth
discussion, see Microsofts 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 theres a match, the search path is updated to use this
tenants schema. So from now on all queries will take place at the
tenants 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! Youll 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 PostgreSQLs ``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 tenants schema,
so you shouldnt 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.
Youre 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
.. _PostgreSQLs 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/

5
changelog Normal file
View File

@ -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 <jschneider@entrouvert.com> Tue, 14 Oct 2014 11:39:16 +0200

1
compat Normal file
View File

@ -0,0 +1 @@
7

11
control Normal file
View File

@ -0,0 +1,11 @@
Source: django-tenant-schemas
Maintainer: Jérôme Schneider <jschneider@entrouvert.com>
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.

61
docs/advanced_usage.rst Normal file
View File

@ -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
}
}

View File

@ -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 ---------------------------------------------------

View File

@ -1,6 +1,6 @@
Welcome to django-tenant-schemas documentation!
===============================================
This application enables `Django <https://www.djangoproject.com/>`_ powered websites to have multiple tenants via `PostgreSQL schemas <http://www.postgresql.org/docs/9.1/static/ddl-schemas.html>`_. A vital feature for every Software-as-a-Service website.
This application enables `Django <https://www.djangoproject.com/>`_ powered websites to have multiple tenants via `PostgreSQL schemas <http://www.postgresql.org/docs/9.2/static/ddl-schemas.html>`_. 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 <http://www.postgresql.org/docs/9.1/static/ddl-schemas.html>`_.
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 <http://www.postgresql.org/docs/9.2/static/ddl-schemas.html>`_.
Why schemas?
------------
@ -48,9 +48,10 @@ Contents
.. toctree::
:maxdepth: 2
install
use
advanced_usage
examples
templates
test

View File

@ -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 <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 <http://south.aeracode.org/>`_ 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 <use.html#creating-a-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 <http://south.aeracode.org/>`_ 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 <https://www.django-cms.org/>`_ 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 <https://www.django-cms.org/>`_ 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 <http://pypi.python.org/pypi/Sphinx>`_. To get started
.. code-block:: bash

View File

@ -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 <https://github.com/bernardopires/django-tenant-schemas>`. :)
Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub <https://github.com/bernardopires/django-tenant-schemas>`_. :)

View File

@ -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/

View File

@ -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 <https://docs.djangoproject.com/en/1.11/internals/release-process/>`_ and the present list of `Supported Versions <https://www.djangoproject.com/download/#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``.

1
dts_test_project/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.coverage

View File

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

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

@ -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,
},
},
}

View File

@ -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 = []

View File

@ -52,7 +52,7 @@
<a href="http://{{ tenant.domain_url }}:8000">{{ tenant.name }}</a> &middot;
{% endfor %}</p>
<p style="float: right;">
<a href="https://github.com/bernardopires/django-tenant-schemas">django-tenant-schemas</a> &middot; <a href="https://django-tenant-schemas.readthedocs.org/en/latest/">Documentation</a>
<a href="https://github.com/bernardopires/django-tenant-schemas">django-tenant-schemas</a> &middot; <a href="https://django-tenant-schemas.readthedocs.io/en/latest/">Documentation</a>
</p>
</div>
</body></html>

View File

@ -18,11 +18,7 @@
</ul><br>
<p>Just run the command below on your shell to sync <code>SHARED_APPS</code>. Make sure your environment
has <code>Django</code> and <code>django-tenant-schemas</code> available.</p>
{% if DJANGO17 %}
<pre>$ python manage.py migrate_schemas --shared</pre>
{% else %}
<pre>$ python manage.py sync_schemas --shared</pre>
{% endif %}
<p>When you're done refresh this page.</p>
{% elif no_public_tenant %}
<h2>Second Step: Create a public tenant</h2>
@ -124,9 +120,8 @@ Client(domain_url='tenant2.trendy-sass.com',
<h3>Where to go from here</h3>
<p>There are some interesting features that we did not cover.</p>
<ul>
<li><a href="https://django-tenant-schemas.readthedocs.org/en/latest/install.html#south-migrations">South Migrations</a>. This app supports <code>South</code> migrations.</li>
<li><a href="https://django-tenant-schemas.readthedocs.org/en/latest/install.html#tenant-view-routing">Tenant View-Routing</a>. Serve different views for the same path. (this tutorial makes use of this feature)</li>
<li><a href="https://django-tenant-schemas.readthedocs.org/en/latest/use.html#management-commands">Management Commands</a>. Run a command for a particular tenant.</li>
<li><a href="https://django-tenant-schemas.readthedocs.io/en/latest/install.html#tenant-view-routing">Tenant View-Routing</a>. Serve different views for the same path. (this tutorial makes use of this feature)</li>
<li><a href="https://django-tenant-schemas.readthedocs.io/en/latest/use.html#management-commands">Management Commands</a>. Run a command for a particular tenant.</li>
</ul>
{% endif %}
{% endblock %}

View File

@ -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'

View File

@ -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()),
]

View File

@ -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()),
]

View File

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

View File

@ -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.

2
files Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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=

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
python3:Depends=python3-psycopg2, python3-django, python3:any (>= 3.3.2-2~)
misc:Depends=

7
rules Executable file
View File

@ -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

9
setup.cfg Normal file
View File

@ -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

View File

@ -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,

1
source/format Normal file
View File

@ -0,0 +1 @@
3.0 (quilt)

1
source/options Normal file
View File

@ -0,0 +1 @@
extend-diff-ignore="\.egg-info$"

View File

@ -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'

104
tenant_schemas/apps.py Normal file
View File

@ -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

15
tenant_schemas/log.py Normal file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
from tenant_schemas.management.commands import BaseTenantCommand
class Command(BaseTenantCommand):
COMMAND_NAME = 'collectstatic'

View File

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

View File

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

View File

@ -1,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

View File

@ -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)

View File

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

View File

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

View File

@ -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. <my_tenant>.<my_domain>
"""
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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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`

55
tenant_schemas/storage.py Normal file
View File

@ -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.
"""

View File

@ -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

View File

@ -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()

View File

@ -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 *

View File

@ -1,4 +1,3 @@
from django.db import models
from tenant_schemas.models import TenantMixin

View File

@ -0,0 +1 @@
from .test_cached_template_loader import CachedLoaderTests

View File

@ -0,0 +1 @@
Hello! (Django templates)

View File

@ -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")

View File

@ -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"),
])

View File

@ -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, '')

View File

@ -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)

View File

@ -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()))

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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]

31
tox.ini Normal file
View File

@ -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/*

View File

@ -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: