├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGES.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── __init__.py ├── docs ├── Makefile ├── advanced_usage.rst ├── conf.py ├── examples.rst ├── index.rst ├── install.rst ├── involved.rst ├── templates.rst ├── test.rst └── use.rst ├── dpt_test_project ├── .gitignore ├── customers │ ├── __init__.py │ └── models.py ├── dpt_test_app │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_test_drop_unique.py │ │ ├── 0003_test_add_db_index.py │ │ ├── 0004_test_alter_unique.py │ │ └── __init__.py │ └── models.py ├── dpt_test_project │ ├── __init__.py │ ├── settings.py │ └── urls.py └── manage.py ├── examples └── tenant_tutorial │ ├── customers │ ├── __init__.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ └── views.py │ ├── manage.py │ ├── templates │ ├── base.html │ ├── index_public.html │ └── index_tenant.html │ └── tenant_tutorial │ ├── __init__.py │ ├── middleware.py │ ├── settings.py │ ├── urls_public.py │ ├── urls_tenants.py │ ├── views.py │ └── wsgi.py ├── setup.cfg ├── setup.py ├── tenant_schemas ├── __init__.py ├── apps.py ├── cache.py ├── log.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── collectstatic_schemas.py │ │ ├── list_tenants.py │ │ ├── migrate.py │ │ ├── migrate_schemas.py │ │ └── tenant_command.py ├── middleware.py ├── migration_executors │ ├── __init__.py │ ├── base.py │ ├── parallel.py │ └── standard.py ├── models.py ├── postgresql_backend │ ├── __init__.py │ ├── base.py │ └── introspection.py ├── routers.py ├── signals.py ├── storage.py ├── template_loaders.py ├── templatetags │ ├── __init__.py │ └── tenant.py ├── test │ ├── __init__.py │ ├── cases.py │ └── client.py ├── tests │ ├── __init__.py │ ├── models.py │ ├── template_loader │ │ ├── __init__.py │ │ ├── templates │ │ │ └── hello.html │ │ ├── test_cached_template_loader.py │ │ ├── test_filesystem_template_loader.py │ │ └── themes │ │ │ └── tenant.test.com │ │ │ └── templates │ │ │ └── hello.html │ ├── test_apps.py │ ├── test_cache.py │ ├── test_log.py │ ├── test_routes.py │ ├── test_tenants.py │ ├── test_utils.py │ └── testcases.py ├── urlresolvers.py └── utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | # Unix-style newlines with a newline ending every file 6 | [*] 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{js,py}] 12 | charset = utf-8 13 | 14 | [*.py] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [*.{css,js,less}] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | *.swp 4 | .DS_Store 5 | VERSION 6 | _build/ 7 | dist/ 8 | django_tenant_schemas.egg-info 9 | .eggs/ 10 | .idea/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | python: 4 | - 3.6 5 | - 3.7 6 | - 3.8 7 | - 3.9 8 | matrix: 9 | fast_finish: true 10 | services: 11 | - postgresql 12 | addons: 13 | postgresql: '12' 14 | apt: 15 | packages: 16 | - postgresql-12 17 | - postgresql-client-12 18 | - postgresql-server-dev-12 19 | install: 20 | - pip install -q tox-travis 21 | before_script: 22 | - psql -c "CREATE DATABASE dpt_test_project;" -U travis 23 | script: 24 | - tox 25 | env: 26 | global: 27 | - PGPORT=5433 28 | - PGUSER=travis 29 | jobs: 30 | - DJANGO=22 31 | - DJANGO=31 32 | - DJANGO=32 33 | deploy: 34 | provider: pypi 35 | user: vintasoftware 36 | password: 37 | secure: roK/f00ntmwhn2NROdOO8ICfhumw1FwpJxzWChW5XijgD/73vsSyqgCTjWwNCDny2DQt+/ggV5eyF/AF0CqHQL21VgBB4V10JEHfH2dRhhN27JORkdSRTyI1lUqFwWNr91h6it3h3/Dg1ws9Ycdx4WC4RNMZDRRa08Ew+i0Wqu8tqJPG5mgnyzBwG4B4pYkd+gqjoNJQUo1A8mPsfDH1bnfqBR9mXoNPIcXBU8SPFEQwrA83TOOZk+CckIayJ57yf0CwBk+oFhAwCpSpT760/oP3w9tuxmxX/y3Ao/lExVTTneXTsKO+tVnluNSjguLkhmFitLVZFOZvLq9z4AZHgaE/AlJDDeOiPirrd2s+7YLSalYJsLLaElDs0U8qc77uBa3pvKUeDrYgp+eQ7atXDivRY1OOxjhLruHfk3HMlVXFzoETD5mCMs3qD3qKrNuUoc+J7E8zhpe71nGr7DWYRrtTSbzZgP4XASPdcg7rhGGU2mH99pp9VaueZKXbikS2ySQ5xFlaJuoF3ojl+spRSFaqvSaRZzhoQ9HGc4pD+JToBsDNohBtP1rs6E04M0NxhDK6KeldB9JK3SuEyJt+DaKArYP6tLxk0dl8BPrgqmj9Jdc0nNSlMvMvGnSGEzpuPlcjPuQ4VvjdFDQKbINaQ7p7r4IfLHLjG3iEo5uGoRo= 38 | on: 39 | distributions: sdist bdist_wheel 40 | repo: vintasoftware/django-pg-tenants 41 | tags: true 42 | skip_existing: true 43 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Pypi should now correctly contain all files 2 | ---- 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to 3 | deal in the Software without restriction, including without limitation the 4 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 5 | sell copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 16 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 17 | IN THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include LICENSE 3 | include README.rst 4 | include *.txt 5 | 6 | include version.py 7 | include VERSION 8 | recursive-include docs * 9 | recursive-include examples * 10 | recursive-include dts_test_project * 11 | 12 | # exclude all bytecode 13 | global-exclude *.pyo 14 | global-exclude *.pyc 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-pg-tenants 2 | ===================== 3 | 4 | |PyPi version| |PyPi downloads| |Python versions| |Travis CI| |PostgreSQL| 5 | 6 | This is a fork of `django-tenant-schemas`_. 7 | 8 | This application enables `django`_ powered websites to have multiple 9 | tenants via `PostgreSQL schemas`_. A vital feature for every 10 | Software-as-a-Service website. 11 | 12 | Django provides currently no simple way to support multiple tenants 13 | using the same project instance, even when only the data is different. 14 | Because we don't want you running many copies of your project, you'll be 15 | able to have: 16 | 17 | - Multiple customers running on the same instance 18 | - Shared and Tenant-Specific data 19 | - Tenant View-Routing 20 | 21 | What are schemas 22 | ---------------- 23 | 24 | A schema can be seen as a directory in an operating system, each 25 | directory (schema) with it's own set of files (tables and objects). This 26 | allows the same table name and objects to be used in different schemas 27 | without conflict. For an accurate description on schemas, see 28 | `PostgreSQL's official documentation on schemas`_. 29 | 30 | Why schemas 31 | ----------- 32 | 33 | There are typically three solutions for solving the multitenancy 34 | problem. 35 | 36 | 1. Isolated Approach: Separate Databases. Each tenant has it's own 37 | database. 38 | 39 | 2. Semi Isolated Approach: Shared Database, Separate Schemas. One 40 | database for all tenants, but one schema per tenant. 41 | 42 | 3. Shared Approach: Shared Database, Shared Schema. All tenants share 43 | the same database and schema. There is a main tenant-table, where all 44 | other tables have a foreign key pointing to. 45 | 46 | This application implements the second approach, which in our opinion, 47 | represents the ideal compromise between simplicity and performance. 48 | 49 | - Simplicity: barely make any changes to your current code to support 50 | multitenancy. Plus, you only manage one database. 51 | - Performance: make use of shared connections, buffers and memory. 52 | 53 | Each solution has it's up and down sides, for a more in-depth 54 | discussion, see Microsoft's excellent article on `Multi-Tenant Data 55 | Architecture`_. 56 | 57 | How it works 58 | ------------ 59 | 60 | Tenants are identified via their host name (i.e tenant.domain.com). This 61 | information is stored on a table on the ``public`` schema. Whenever a 62 | request is made, the host name is used to match a tenant in the 63 | database. If there's a match, the search path is updated to use this 64 | tenant's schema. So from now on all queries will take place at the 65 | tenant's schema. For example, suppose you have a tenant ``customer`` at 66 | http://customer.example.com. Any request incoming at 67 | ``customer.example.com`` will automatically use ``customer``\ 's schema 68 | and make the tenant available at the request. If no tenant is found, a 69 | 404 error is raised. This also means you should have a tenant for your 70 | main domain, typically using the ``public`` schema. For more information 71 | please read the `setup`_ section. 72 | 73 | What can this app do? 74 | --------------------- 75 | 76 | As many tenants as you want 77 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | Each tenant has its data on a specific schema. Use a single project 80 | instance to serve as many as you want. 81 | 82 | Tenant-specific and shared apps 83 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 84 | 85 | Tenant-specific apps do not share their data between tenants, but you 86 | can also have shared apps where the information is always available and 87 | shared between all. 88 | 89 | Tenant View-Routing 90 | ~~~~~~~~~~~~~~~~~~~ 91 | 92 | You can have different views for ``http://customer.example.com/`` and 93 | ``http://example.com/``, even though Django only uses the string after 94 | the host name to identify which view to serve. 95 | 96 | Magic 97 | ~~~~~ 98 | 99 | Everyone loves magic! You'll be able to have all this barely having to 100 | change your code! 101 | 102 | Setup & Documentation 103 | --------------------- 104 | 105 | **This is just a short setup guide**, it is **strongly** recommended 106 | that you read the complete version at 107 | `django-pg-tenants.readthedocs.io`_. 108 | 109 | Your ``DATABASE_ENGINE`` setting needs to be changed to 110 | 111 | .. code-block:: python 112 | 113 | DATABASES = { 114 | 'default': { 115 | 'ENGINE': 'tenant_schemas.postgresql_backend', 116 | # .. 117 | } 118 | } 119 | 120 | Add the middleware ``tenant_schemas.middleware.TenantMiddleware`` to the 121 | top of ``MIDDLEWARE_CLASSES``, so that each request can be set to use 122 | the correct schema. 123 | 124 | .. code-block:: python 125 | 126 | MIDDLEWARE_CLASSES = ( 127 | 'tenant_schemas.middleware.TenantMiddleware', 128 | #... 129 | ) 130 | 131 | Add ``tenant_schemas.routers.TenantSyncRouter`` to your `DATABASE_ROUTERS` 132 | setting, so that the correct apps can be synced, depending on what's 133 | being synced (shared or tenant). 134 | 135 | .. code-block:: python 136 | 137 | DATABASE_ROUTERS = ( 138 | 'tenant_schemas.routers.TenantSyncRouter', 139 | ) 140 | 141 | Add ``tenant_schemas`` to your ``INSTALLED_APPS``. 142 | 143 | Create your tenant model 144 | ~~~~~~~~~~~~~~~~~~~~~~~~ 145 | 146 | .. code-block:: python 147 | 148 | from django.db import models 149 | from tenant_schemas.models import TenantMixin 150 | 151 | class Client(TenantMixin): 152 | name = models.CharField(max_length=100) 153 | paid_until = models.DateField() 154 | on_trial = models.BooleanField() 155 | created_on = models.DateField(auto_now_add=True) 156 | 157 | Define on ``settings.py`` which model is your tenant model. Assuming you 158 | created ``Client`` inside an app named ``customers``, your 159 | ``TENANT_MODEL`` should look like this: 160 | 161 | .. code-block:: python 162 | 163 | TENANT_MODEL = "customers.Client" # app.Model 164 | 165 | Now run ``migrate_schemas`` to sync your apps to the ``public`` schema. 166 | 167 | :: 168 | 169 | python manage.py migrate_schemas --shared 170 | 171 | Create your tenants just like a normal django model. Calling ``save`` 172 | will automatically create and sync/migrate the schema. 173 | 174 | .. code-block:: python 175 | 176 | from customers.models import Client 177 | 178 | # create your public tenant 179 | tenant = Client(domain_url='tenant.my-domain.com', 180 | schema_name='tenant1', 181 | name='My First Tenant', 182 | paid_until='2014-12-05', 183 | on_trial=True) 184 | tenant.save() 185 | 186 | Any request made to ``tenant.my-domain.com`` will now automatically set 187 | your PostgreSQL's ``search_path`` to ``tenant1`` and ``public``, making 188 | shared apps available too. This means that any call to the methods 189 | ``filter``, ``get``, ``save``, ``delete`` or any other function 190 | involving a database connection will now be done at the tenant's schema, 191 | so you shouldn't need to change anything at your views. 192 | 193 | You're all set, but we have left key details outside of this short 194 | tutorial, such as creating the public tenant and configuring shared and 195 | tenant specific apps. Complete instructions can be found at 196 | `django-pg-tenants.readthedocs.io`_. 197 | 198 | 199 | 200 | .. _django-tenant-schemas: https://github.com/bernardopires/django-tenant-schemas 201 | .. _django: https://www.djangoproject.com/ 202 | .. _PostgreSQL schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html 203 | .. _PostgreSQL's official documentation on schemas: http://www.postgresql.org/docs/9.1/static/ddl-schemas.html 204 | .. _Multi-Tenant Data Architecture: http://msdn.microsoft.com/en-us/library/aa479086.aspx 205 | 206 | .. |PyPi version| image:: https://img.shields.io/pypi/v/django-pg-tenants.svg 207 | :target: https://pypi.python.org/pypi/django-pg-tenants 208 | .. |PyPi downloads| image:: https://img.shields.io/pypi/dm/django-pg-tenants.svg 209 | :target: https://pypi.python.org/pypi/django-pg-tenants 210 | .. |Python versions| image:: https://img.shields.io/pypi/pyversions/django-pg-tenants.svg 211 | .. |Travis CI| image:: https://travis-ci.org/vintasoftware/django-pg-tenants.svg?branch=master 212 | :target: https://travis-ci.org/vintasoftware/django-pg-tenants 213 | .. |PostgreSQL| image:: https://img.shields.io/badge/PostgreSQL-12.7-blue.svg 214 | .. _setup: https://django-pg-tenants.readthedocs.io/en/latest/install.html 215 | .. _django-pg-tenants.readthedocs.io: https://django-pg-tenants.readthedocs.io/en/latest/ 216 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/__init__.py -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | 9 | # Internal variables. 10 | PAPEROPT_a4 = -D latex_paper_size=a4 11 | PAPEROPT_letter = -D latex_paper_size=letter 12 | ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 13 | 14 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 15 | 16 | help: 17 | @echo "Please use \`make ' where is one of" 18 | @echo " html to make standalone HTML files" 19 | @echo " dirhtml to make HTML files named index.html in directories" 20 | @echo " pickle to make pickle files" 21 | @echo " json to make JSON files" 22 | @echo " htmlhelp to make HTML files and a HTML help project" 23 | @echo " qthelp to make HTML files and a qthelp project" 24 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 25 | @echo " changes to make an overview of all changed/added/deprecated items" 26 | @echo " linkcheck to check all external links for integrity" 27 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 28 | 29 | clean: 30 | -rm -rf _build/* 31 | 32 | html: 33 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html 34 | @echo 35 | @echo "Build finished. The HTML pages are in _build/html." 36 | 37 | dirhtml: 38 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml 39 | @echo 40 | @echo "Build finished. The HTML pages are in _build/dirhtml." 41 | 42 | pickle: 43 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle 44 | @echo 45 | @echo "Build finished; now you can process the pickle files." 46 | 47 | json: 48 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) _build/json 49 | @echo 50 | @echo "Build finished; now you can process the JSON files." 51 | 52 | htmlhelp: 53 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp 54 | @echo 55 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 56 | ".hhp project file in _build/htmlhelp." 57 | 58 | qthelp: 59 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) _build/qthelp 60 | @echo 61 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 62 | ".qhcp project file in _build/qthelp, like this:" 63 | @echo "# qcollectiongenerator _build/qthelp/project.qhcp" 64 | @echo "To view the help file:" 65 | @echo "# assistant -collectionFile _build/qthelp/project.qhc" 66 | 67 | latex: 68 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex 69 | @echo 70 | @echo "Build finished; the LaTeX files are in _build/latex." 71 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 72 | "run these through (pdf)latex." 73 | 74 | changes: 75 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes 76 | @echo 77 | @echo "The overview file is in _build/changes." 78 | 79 | linkcheck: 80 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck 81 | @echo 82 | @echo "Link check complete; look for any errors in the above output " \ 83 | "or in _build/linkcheck/output.txt." 84 | 85 | doctest: 86 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest 87 | @echo "Testing of doctests in the sources finished, look at the " \ 88 | "results in _build/doctest/output.txt." 89 | -------------------------------------------------------------------------------- /docs/advanced_usage.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Advanced Usage 3 | ============== 4 | 5 | Custom tenant strategies (custom middleware support) 6 | ==================================================== 7 | By default, ``django-pg-tenants``'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``. 8 | 9 | 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-pg-tenants`` offer an **easily extensible way to provide your own middleware** with minimal code changes. 10 | 11 | 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. 12 | 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. 13 | 14 | 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``. 15 | 16 | 17 | Example: Determine tenant from HTTP header 18 | ------------------------------------------ 19 | Suppose you wanted to determine the current tenant based on a request header (``X-DTS-SCHEMA``). You might implement a simple middleware such as: 20 | 21 | .. code-block:: python 22 | 23 | class XHeaderTenantMiddleware(BaseTenantMiddleware): 24 | """ 25 | Determines tenant by the value of the ``X-DTS-SCHEMA`` HTTP header. 26 | """ 27 | def get_tenant(self, model, hostname, request): 28 | schema_name = request.META.get('HTTP_X_DTS_SCHEMA', get_public_schema_name()) 29 | return model.objects.get(schema_name=schema_name) 30 | 31 | 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: 32 | 33 | 34 | .. code-block:: nginx 35 | 36 | # /etc/nginx/conf.d/multitenant.conf 37 | 38 | upstream web { 39 | server localhost:8000; 40 | } 41 | 42 | server { 43 | listen 80 default_server; 44 | server_name _; 45 | 46 | location / { 47 | proxy_pass http://web; 48 | proxy_set_header Host $host; 49 | } 50 | } 51 | 52 | server { 53 | listen 80; 54 | server_name example.com www.example.com; 55 | 56 | location / { 57 | proxy_pass http://web; 58 | proxy_set_header Host $host; 59 | proxy_set_header X-DTS-SCHEMA example; # triggers XHeaderTenantMiddleware 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # dinnertime documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Aug 19 10:27:46 2009. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import datetime 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.append(os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.autosectionlabel',] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ['_templates'] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = '.rst' 32 | 33 | # The encoding of source files. 34 | #source_encoding = 'utf-8' 35 | 36 | # The master toctree document. 37 | master_doc = 'index' 38 | 39 | # General information about the project. 40 | project = 'django-pg-tenants' 41 | copyright = '%d, Vinta Software' % datetime.date.today().year 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | try: 48 | from compressor import __version__ 49 | # The short X.Y version. 50 | version = '.'.join(__version__.split('.')[:2]) 51 | # The full version, including alpha/beta/rc tags. 52 | release = __version__ 53 | except ImportError: 54 | version = release = 'dev' 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of documents that shouldn't be included in the build. 67 | #unused_docs = [] 68 | 69 | # List of directories, relative to source directory, that shouldn't be searched 70 | # for source files. 71 | exclude_trees = ['_build'] 72 | 73 | # The reST default role (used for this markup: `text`) to use for all documents. 74 | #default_role = None 75 | 76 | # If true, '()' will be appended to :func: etc. cross-reference text. 77 | #add_function_parentheses = True 78 | 79 | # If true, the current module name will be prepended to all description 80 | # unit titles (such as .. function::). 81 | #add_module_names = True 82 | 83 | # If true, sectionauthor and moduleauthor directives will be shown in the 84 | # output. They are ignored by default. 85 | #show_authors = False 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = 'sphinx' 89 | 90 | # A list of ignored prefixes for module index sorting. 91 | #modindex_common_prefix = [] 92 | 93 | intersphinx_mapping = { 94 | 'django': ( 95 | 'https://docs.djangoproject.com/en/1.11/', 96 | 'https://docs.djangoproject.com/en/1.11/_objects/'), 97 | } 98 | 99 | # -- Options for HTML output --------------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. Major themes that come with 102 | # Sphinx are currently 'default' and 'sphinxdoc'. 103 | html_theme = 'default' 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | #html_theme_options = {} 109 | 110 | # Add any paths that contain custom themes here, relative to this directory. 111 | #html_theme_path = [] 112 | 113 | # The name for this set of Sphinx documents. If None, it defaults to 114 | # " v documentation". 115 | #html_title = None 116 | 117 | # A shorter title for the navigation bar. Default is the same as html_title. 118 | #html_short_title = None 119 | 120 | # The name of an image file (relative to this directory) to place at the top 121 | # of the sidebar. 122 | #html_logo = None 123 | 124 | # The name of an image file (within the static path) to use as favicon of the 125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 126 | # pixels large. 127 | #html_favicon = None 128 | 129 | # Add any paths that contain custom static files (such as style sheets) here, 130 | # relative to this directory. They are copied after the builtin static files, 131 | # so a file named "default.css" will overwrite the builtin "default.css". 132 | html_static_path = ['_static'] 133 | 134 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 135 | # using the given strftime format. 136 | #html_last_updated_fmt = '%b %d, %Y' 137 | 138 | # If true, SmartyPants will be used to convert quotes and dashes to 139 | # typographically correct entities. 140 | #html_use_smartypants = True 141 | 142 | # Custom sidebar templates, maps document names to template names. 143 | #html_sidebars = {} 144 | 145 | # Additional templates that should be rendered to pages, maps page names to 146 | # template names. 147 | #html_additional_pages = {} 148 | 149 | # If false, no module index is generated. 150 | #html_use_modindex = True 151 | 152 | # If false, no index is generated. 153 | #html_use_index = True 154 | 155 | # If true, the index is split into individual pages for each letter. 156 | #html_split_index = False 157 | 158 | # If true, links to the reST sources are added to the pages. 159 | #html_show_sourcelink = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = '' 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'tenantschemasdoc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | # The paper size ('letter' or 'a4'). 176 | #latex_paper_size = 'letter' 177 | 178 | # The font size ('10pt', '11pt' or '12pt'). 179 | #latex_font_size = '10pt' 180 | 181 | # Grouping the document tree into LaTeX files. List of tuples 182 | # (source start file, target name, title, author, documentclass [howto/manual]). 183 | latex_documents = [ 184 | ('index', 'tenantschema.tex', 'tenantschemaDocumentation', 'Bernardo Pires Carneiro', 'manual'), 185 | ] 186 | 187 | # The name of an image file (relative to this directory) to place at the top of 188 | # the title page. 189 | #latex_logo = None 190 | 191 | # For "manual" documents, if this is true, then toplevel headings are parts, 192 | # not chapters. 193 | #latex_use_parts = False 194 | 195 | # Additional stuff for the LaTeX preamble. 196 | #latex_preamble = '' 197 | 198 | # Documents to append as an appendix to all manuals. 199 | #latex_appendices = [] 200 | 201 | # If false, no module index is generated. 202 | #latex_use_modindex = True 203 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Examples 3 | =========================== 4 | Tenant Tutorial 5 | ----------------- 6 | This app comes with an interactive tutorial to teach you how to use ``django-pg-tenants`` and to demonstrate its capabilities. This example project is available under `examples/tenant_tutorial `_. You will only need to edit the ``settings.py`` file to configure the ``DATABASES`` variable and then you're ready to run 7 | 8 | .. code-block:: bash 9 | 10 | ./manage.py runserver 11 | 12 | All other steps will be explained by following the tutorial, just open ``http://127.0.0.1:8000`` on your browser. 13 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to django-pg-tenants documentation! 2 | =============================================== 3 | This application enables `Django `_ powered websites to have multiple tenants via `PostgreSQL schemas `_. A vital feature for every Software-as-a-Service website. 4 | 5 | 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: 6 | 7 | * Multiple customers running on the same instance 8 | * Shared and Tenant-Specific data 9 | * Tenant View-Routing 10 | 11 | What are schemas? 12 | ----------------- 13 | 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 `_. 14 | 15 | Why schemas? 16 | ------------ 17 | There are typically three solutions for solving the multitenancy problem. 18 | 19 | 1. Isolated Approach: Separate Databases. Each tenant has it's own database. 20 | 21 | 2. Semi Isolated Approach: Shared Database, Separate Schemas. One database for all tenants, but one schema per tenant. 22 | 23 | 3. Shared Approach: Shared Database, Shared Schema. All tenants share the same database and schema. There is a main tenant-table, where all other tables have a foreign key pointing to. 24 | 25 | This application implements the second approach, which in our opinion, represents the ideal compromise between simplicity and performance. 26 | 27 | * Simplicity: barely make any changes to your current code to support multitenancy. Plus, you only manage one database. 28 | * Performance: make use of shared connections, buffers and memory. 29 | 30 | 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 `_. 31 | 32 | How it works 33 | ------------ 34 | Tenants are identified via their host name (i.e tenant.domain.com). This information is stored on a table on the ``public`` schema. Whenever a request is made, the host name is used to match a tenant in the database. If there's a match, the search path is updated to use this tenant's schema. So from now on all queries will take place at the tenant's schema. For example, suppose you have a tenant ``customer`` at http://customer.example.com. Any request incoming at ``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 please read the [setup](#setup) section. 35 | 36 | Shared and Tenant-Specific Applications 37 | --------------------------------------- 38 | Tenant-Specific Applications 39 | ++++++++++++++++++++++++++++ 40 | Most of your applications are probably tenant-specific, that is, its data is not to be shared with any of the other tenants. 41 | 42 | Shared Applications 43 | +++++++++++++++++++ 44 | An application is considered to be shared when its tables are in the ``public`` schema. Some apps make sense being shared. Suppose you have some sort of public data set, for example, a table containing census data. You want every tenant to be able to query it. This application enables shared apps by always adding the ``public`` schema to the search path, making these apps also always available. 45 | 46 | Contents 47 | -------- 48 | 49 | .. toctree:: 50 | :maxdepth: 2 51 | 52 | install 53 | use 54 | advanced_usage 55 | examples 56 | templates 57 | test 58 | involved 59 | 60 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Assuming you have django installed, the first step is to install ``django-pg-tenants``. 6 | 7 | .. code-block:: bash 8 | 9 | pip install django-pg-tenants 10 | 11 | Basic Settings 12 | ============== 13 | You'll have to make the following modifications to your ``settings.py`` file. 14 | 15 | Your ``DATABASE_ENGINE`` setting needs to be changed to 16 | 17 | .. code-block:: python 18 | 19 | DATABASES = { 20 | 'default': { 21 | 'ENGINE': 'tenant_schemas.postgresql_backend', 22 | # .. 23 | } 24 | } 25 | 26 | 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). 27 | 28 | .. code-block:: python 29 | 30 | DATABASE_ROUTERS = ( 31 | 'tenant_schemas.routers.TenantSyncRouter', 32 | ) 33 | 34 | 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. 35 | 36 | If the hostname in the request does not match a valid tenant ``domain_url``, a HTTP 404 Not Found will be returned. 37 | 38 | If you'd like to raise ``DisallowedHost`` and a HTTP 400 response instead, use the ``tenant_schemas.middleware.SuspiciousTenantMiddleware``. 39 | 40 | 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. 41 | 42 | 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`. 43 | 44 | .. code-block:: python 45 | 46 | from tenant_schemas.middleware import DefaultTenantMiddleware 47 | 48 | class MyDefaultTenantMiddleware(DefaultTenantMiddleware): 49 | DEFAULT_SCHEMA_NAME = 'default' 50 | 51 | .. code-block:: python 52 | 53 | MIDDLEWARE_CLASSES = ( 54 | 'tenant_schemas.middleware.TenantMiddleware', 55 | # 'tenant_schemas.middleware.SuspiciousTenantMiddleware', 56 | # 'tenant_schemas.middleware.DefaultTenantMiddleware', 57 | # 'myproject.middleware.MyDefaultTenantMiddleware', 58 | #... 59 | ) 60 | 61 | .. code-block:: python 62 | 63 | TEMPLATES = [ 64 | { 65 | 'BACKEND': # ... 66 | 'DIRS': [], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | # ... 71 | 'django.template.context_processors.request', 72 | # ... 73 | ] 74 | } 75 | } 76 | ] 77 | 78 | The Tenant Model 79 | ================ 80 | 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``. 81 | 82 | .. code-block:: python 83 | 84 | from django.db import models 85 | from tenant_schemas.models import TenantMixin 86 | 87 | class Client(TenantMixin): 88 | name = models.CharField(max_length=100) 89 | paid_until = models.DateField() 90 | on_trial = models.BooleanField() 91 | created_on = models.DateField(auto_now_add=True) 92 | 93 | # default true, schema will be automatically created and synced when it is saved 94 | auto_create_schema = True 95 | 96 | Before creating the migrations, we must configure a few specific settings. 97 | 98 | Configure Tenant and Shared Applications 99 | ======================================== 100 | 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 101 | 102 | .. code-block:: python 103 | 104 | SHARED_APPS = ( 105 | 'tenant_schemas', # mandatory, should always be before any django app 106 | 'customers', # you must list the app where your tenant model resides in 107 | 108 | 'django.contrib.contenttypes', 109 | 110 | # everything below here is optional 111 | 'django.contrib.auth', 112 | 'django.contrib.sessions', 113 | 'django.contrib.sites', 114 | 'django.contrib.messages', 115 | 'django.contrib.admin', 116 | ) 117 | 118 | TENANT_APPS = ( 119 | 'django.contrib.contenttypes', 120 | 121 | # your tenant-specific apps 122 | 'myapp.hotels', 123 | 'myapp.houses', 124 | ) 125 | 126 | INSTALLED_APPS = ( 127 | 'tenant_schemas', # mandatory, should always be before any django app 128 | 129 | 'customers', 130 | 'django.contrib.contenttypes', 131 | 'django.contrib.auth', 132 | 'django.contrib.sessions', 133 | 'django.contrib.sites', 134 | 'django.contrib.messages', 135 | 'django.contrib.admin', 136 | 'myapp.hotels', 137 | 'myapp.houses', 138 | ) 139 | 140 | You also have to set where your tenant model is. 141 | 142 | .. code-block:: python 143 | 144 | TENANT_MODEL = "customers.Client" # app.Model 145 | 146 | Now you must create your app migrations for ``customers``: 147 | 148 | .. code-block:: bash 149 | 150 | python manage.py makemigrations customers 151 | 152 | 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. 153 | 154 | .. code-block:: bash 155 | 156 | python manage.py migrate_schemas --shared 157 | 158 | .. warning:: 159 | 160 | Never use ``migrate`` as it would sync *all* your apps to ``public``! 161 | 162 | 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 `. 163 | 164 | You can also specify extra schemas that should be visible to all queries using 165 | ``PG_EXTRA_SEARCH_PATHS`` setting. 166 | 167 | .. code-block:: python 168 | 169 | PG_EXTRA_SEARCH_PATHS = ['extensions'] 170 | 171 | ``PG_EXTRA_SEARCH_PATHS`` should be a list of schemas you want to make visible 172 | globally. 173 | 174 | .. tip:: 175 | 176 | You can create a dedicated schema to hold postgresql extensions and make it 177 | available globally. This helps avoid issues caused by hiding the public 178 | schema from queries. 179 | 180 | Working with Tenant specific schemas 181 | ==================================== 182 | Since each Tenant has it's own schema in the database you need a way to tell Django what 183 | schema to use when using the management commands. 184 | 185 | A special management command ``tenant_command`` has been added to allow you to 186 | execute Django management commands in the context of a specific Tenant schema. 187 | 188 | .. code-block:: python 189 | 190 | python manage.py tenant_command loaddata --schema=my_tenant test_fixture 191 | 192 | .. warning:: 193 | 194 | Depending on the configuration of your applications, the command you execute 195 | may impact shared data also. 196 | 197 | Creating a new Tenant 198 | ===================== 199 | See `Creating a new Tenant `_ for more details on how to create a new Tenant in our 200 | application. 201 | 202 | 203 | Optional Settings 204 | ================= 205 | 206 | .. attribute:: PUBLIC_SCHEMA_NAME 207 | 208 | :Default: ``'public'`` 209 | 210 | The schema name that will be treated as ``public``, that is, where the ``SHARED_APPS`` will be created. 211 | 212 | Tenant View-Routing 213 | ------------------- 214 | 215 | .. attribute:: PUBLIC_SCHEMA_URLCONF 216 | 217 | :Default: ``None`` 218 | 219 | We have a goodie called ``PUBLIC_SCHEMA_URLCONF``. Suppose you have your main website at ``example.com`` and a customer at ``customer.example.com``. You probably want your user to be routed to different views when someone requests ``http://example.com/`` and ``http://customer.example.com/``. Because django only uses the string after the host name, this would be impossible, both would call the view at ``/``. This is where ``PUBLIC_SCHEMA_URLCONF`` comes in handy. If set, when the ``public`` schema is being requested, the value of this variable will be used instead of `ROOT_URLCONF `_. So for example, if you have 220 | 221 | .. code-block:: python 222 | 223 | PUBLIC_SCHEMA_URLCONF = 'myproject.urls_public' 224 | 225 | 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``. 226 | 227 | Separate projects for the main website and tenants (optional) 228 | ------------------------------------------------------------- 229 | In some cases using the ``PUBLIC_SCHEMA_URLCONF`` can be difficult. For example, `Django CMS `_ takes some control over the default Django URL routing by using middlewares that do not play well with the tenants. Another example would be when some apps on the main website need different settings than the tenants website. In these cases it is much simpler if you just run the main website `example.com` as a separate application. 230 | 231 | 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``. 232 | 233 | .. code-block:: python 234 | 235 | # wsgi_main_website.py 236 | import os 237 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings_public") 238 | 239 | from django.core.wsgi import get_wsgi_application 240 | application = get_wsgi_application() 241 | 242 | If you put this in the same Django project, you can make a new ``settings_public.py`` which points to a different ``urls_public.py``. This has the advantage that you can use the same apps that you use for your tenant websites. 243 | 244 | Or you can create a completely separate project for the main website. 245 | 246 | Caching 247 | ------- 248 | 249 | To enable tenant aware caching you can set the `KEY_FUNCTION `_ setting to use the provided ``make_key`` helper function which 250 | adds the tenants ``schema_name`` as the first key prefix. 251 | 252 | .. code-block:: python 253 | 254 | CACHES = { 255 | "default": { 256 | ... 257 | 'KEY_FUNCTION': 'tenant_schemas.cache.make_key', 258 | 'REVERSE_KEY_FUNCTION': 'tenant_schemas.cache.reverse_key', 259 | }, 260 | } 261 | 262 | The ``REVERSE_KEY_FUNCTION`` setting is only required if you are using the `django-redis `_ cache backend. 263 | 264 | Configuring your Apache Server (optional) 265 | ========================================= 266 | Here's how you can configure your Apache server to route all subdomains to your django project so you don't have to setup any subdomains manually. 267 | 268 | .. code-block:: apacheconf 269 | 270 | 271 | ServerName mywebsite.com 272 | ServerAlias *.mywebsite.com mywebsite.com 273 | WSGIScriptAlias / "/path/to/django/scripts/mywebsite.wsgi" 274 | 275 | 276 | `Django's Deployment with Apache and mod_wsgi `_ might interest you too. 277 | 278 | Building Documentation 279 | ====================== 280 | Documentation is available in ``docs`` and can be built into a number of 281 | formats using `Sphinx `_. To get started 282 | 283 | .. code-block:: bash 284 | 285 | pip install Sphinx 286 | cd docs 287 | make html 288 | 289 | This creates the documentation in HTML format at ``docs/_build/html``. 290 | 291 | -------------------------------------------------------------------------------- /docs/involved.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Get Involved! 3 | ============= 4 | Suggestions, bugs, ideas, patches, questions 5 | -------------------------------------------- 6 | Are **highly** welcome! Feel free to write an issue for any feedback you have or send a pull request on `GitHub `_. :) 7 | -------------------------------------------------------------------------------- /docs/templates.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Specializing templates based on tenants 3 | ======================================= 4 | 5 | Multitenant aware filesystem template loader 6 | -------------------------------------------- 7 | 8 | The regular Django filesystem template loader does not vary the search path based on the current tenant. We provide a specialised version which does adapt. To use it add, add ``tenant_schemas.template_loaders.FilesystemLoader`` to your ``TEMPLATES`` configuration. 9 | 10 | .. code-block:: python 11 | 12 | TEMPLATES = [ 13 | { 14 | "BACKEND": "django.template.backends.django.DjangoTemplates", 15 | "DIRS": ["/path/to/templates"], 16 | ... 17 | "OPTIONS": { 18 | ... 19 | "loaders": [ 20 | "tenant_schemas.template_loaders.FilesystemLoader", 21 | "django.template.loaders.app_directories.Loader", 22 | ] 23 | } 24 | } 25 | ] 26 | 27 | MULTITENANT_TEMPLATE_DIRS = ["/path/to/tenant/templates/%s"] 28 | 29 | Like with the Django ``FilesystemLoader`` the first file found is used. The loader will first search for templates in the paths specified in ``MULTITENANT_TEMPLATE_DIRS`` before falling back to the static locations in the ``DIRS`` option. 30 | 31 | The replacement string ``%s`` will be transposed with the tenant ``domain_url`` in ``MULTITENANT_TEMPLATE_DIRS``. 32 | 33 | Multitenant aware cached template loader 34 | ---------------------------------------- 35 | 36 | To use template caching with the ``FilesystemLoader``, you must combine it with the ``CachedLoader``. If you do not, the first template located for any tenant is used for all following tenants. 37 | 38 | The ``CachedLoader`` prefixes the cache key with the schema name of the tenant. 39 | 40 | .. code-block:: python 41 | 42 | TEMPLATES = [ 43 | { 44 | "BACKEND": "django.template.backends.django.DjangoTemplates", 45 | "DIRS": ["/path/to/templates"], 46 | ... 47 | "OPTIONS": { 48 | ... 49 | "loaders": [ 50 | ( 51 | "tenant_schemas.template_loaders.CachedLoader", 52 | ( 53 | "tenant_schemas.template_loaders.FilesystemLoader", 54 | "django.template.loaders.app_directories.Loader", 55 | ) 56 | ) 57 | ] 58 | } 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /docs/test.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Tests 3 | ===== 4 | 5 | Running the tests 6 | ----------------- 7 | 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``. 8 | 9 | .. code-block:: bash 10 | 11 | ./manage.py test tenant_schemas.tests 12 | 13 | To run the test suite outsite of your application you can use tox_ to test all supported Django versions. 14 | 15 | .. code-block:: bash 16 | 17 | tox 18 | 19 | Updating your app's tests to work with tenant-schemas 20 | ----------------------------------------------------- 21 | 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 22 | 23 | .. code-block:: python 24 | 25 | from tenant_schemas.test.cases import TenantTestCase 26 | from tenant_schemas.test.client import TenantClient 27 | 28 | class BaseSetup(TenantTestCase): 29 | def setUp(self): 30 | self.c = TenantClient(self.tenant) 31 | 32 | def test_user_profile_view(self): 33 | response = self.c.get(reverse('user_profile')) 34 | self.assertEqual(response.status_code, 200) 35 | 36 | 37 | Running tests faster 38 | -------------------- 39 | 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. 40 | 41 | .. code-block:: python 42 | 43 | from tenant_schemas.test.cases import FastTenantTestCase 44 | 45 | 46 | .. _tox: https://tox.readthedocs.io/ 47 | -------------------------------------------------------------------------------- /docs/use.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Using django-pg-tenants 3 | =========================== 4 | 5 | Supported versions 6 | ------------------ 7 | You can use ``django-pg-tenants`` with currently maintained versions of Django -- see the `Django's release process `_ and the present list of `Supported Versions `_. 8 | 9 | It is necessary to use a PostgreSQL database. ``django-pg-tenants`` will ensure compatibility with the minimum required version of the latest Django release. At this time that is PostgreSQL 12.7, the minimum for Django 2.2. 10 | 11 | Creating a Tenant 12 | ----------------- 13 | 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``. 14 | 15 | .. code-block:: python 16 | 17 | from customers.models import Client 18 | 19 | # create your public tenant 20 | tenant = Client(domain_url='my-domain.com', # don't add your port or www here! on a local server you'll want to use localhost here 21 | schema_name='public', 22 | name='Schemas Inc.', 23 | paid_until='2016-12-05', 24 | on_trial=False) 25 | tenant.save() 26 | 27 | Now we can create our first real tenant. 28 | 29 | .. code-block:: python 30 | 31 | from customers.models import Client 32 | 33 | # create your first real tenant 34 | tenant = Client(domain_url='tenant.my-domain.com', # don't add your port or www here! 35 | schema_name='tenant1', 36 | name='Fonzy Tenant', 37 | paid_until='2014-12-05', 38 | on_trial=True) 39 | tenant.save() # migrate_schemas automatically called, your tenant is ready to be used! 40 | 41 | Because you have the tenant middleware installed, any request made to ``tenant.my-domain.com`` will now automatically set your PostgreSQL's ``search_path`` to ``tenant1, public``, making shared apps available too. The tenant will be made available at ``request.tenant``. By the way, the current schema is also available at ``connection.schema_name``, which is useful, for example, if you want to hook to any of django's signals. 42 | 43 | Any call to the methods ``filter``, ``get``, ``save``, ``delete`` or any other function involving a database connection will now be done at the tenant's schema, so you shouldn't need to change anything at your views. 44 | 45 | Management commands 46 | ------------------- 47 | By default, base commands run on the public tenant but you can also own commands that run on a specific tenant by inheriting ``BaseTenantCommand``. 48 | 49 | For example, if you have the following ``do_foo`` command in the ``foo`` app: 50 | 51 | ``foo/management/commands/do_foo.py`` 52 | 53 | .. code-block:: python 54 | 55 | from django.core.management.base import BaseCommand 56 | 57 | class Command(BaseCommand): 58 | def handle(self, *args, **options): 59 | do_foo() 60 | 61 | You could create a wrapper command by using ``BaseTenantCommand``: 62 | 63 | ``foo/management/commands/tenant_do_foo.py`` 64 | 65 | .. code-block:: python 66 | 67 | from tenant_schemas.management.commands import BaseTenantCommand 68 | 69 | class Command(BaseTenantCommand): 70 | COMMAND_NAME = 'do_foo' 71 | 72 | To run the command on a particular schema, there is an optional argument called ``--schema``. 73 | 74 | .. code-block:: bash 75 | 76 | ./manage.py tenant_command do_foo --schema=customer1 77 | 78 | If you omit the ``schema`` argument, the interactive shell will ask you to select one. 79 | 80 | migrate_schemas 81 | ~~~~~~~~~~~~~~~ 82 | 83 | ``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. 84 | 85 | .. warning:: 86 | 87 | You should never directly call ``migrate``. We perform some magic in order to make ``migrate`` only migrate the appropriate apps. 88 | 89 | .. code-block:: bash 90 | 91 | ./manage.py migrate_schemas 92 | 93 | The options given to ``migrate_schemas`` are also passed to every ``migrate``. Hence you may find handy 94 | 95 | .. code-block:: bash 96 | 97 | ./manage.py migrate_schemas --list 98 | 99 | ``migrate_schemas`` raises an exception when an tenant schema is missing. 100 | 101 | migrate_schemas in parallel 102 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 103 | 104 | 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: 105 | 106 | .. code-block:: bash 107 | 108 | python manage.py migrate_schemas --executor=parallel 109 | 110 | In fact, you can write your own executor which will run tenant migrations in 111 | any way you want, just take a look at ``tenant_schemas/migration_executors``. 112 | 113 | The ``parallel`` executor accepts the following settings: 114 | 115 | * ``TENANT_PARALLEL_MIGRATION_MAX_PROCESSES`` (default: 2) - maximum number of 116 | processes for migration pool (this is to avoid exhausting the database 117 | connection pool) 118 | * ``TENANT_PARALLEL_MIGRATION_CHUNKS`` (default: 2) - number of migrations to be 119 | sent at once to every worker 120 | 121 | tenant_command 122 | ~~~~~~~~~~~~~~ 123 | 124 | To run any command on an individual schema, you can use the special ``tenant_command``, which creates a wrapper around your command so that it only runs on the schema you specify. For example 125 | 126 | .. code-block:: bash 127 | 128 | ./manage.py tenant_command loaddata 129 | 130 | If you don't specify a schema, you will be prompted to enter one. Otherwise, you may specify a schema preemptively 131 | 132 | .. code-block:: bash 133 | 134 | ./manage.py tenant_command loaddata --schema=customer1 135 | 136 | createsuperuser 137 | ~~~~~~~~~~~~~~~ 138 | 139 | The command ``createsuperuser`` is already automatically wrapped to have a ``schema`` flag. Create a new super user with 140 | 141 | .. code-block:: bash 142 | 143 | ./manage.py createsuperuser --username=admin --schema=customer1 144 | 145 | 146 | list_tenants 147 | ~~~~~~~~~~~~ 148 | 149 | Prints to standard output a tab separated list of schema:domain_url values for each tenant. 150 | 151 | .. code-block:: bash 152 | 153 | for t in $(./manage.py list_tenants | cut -f1); 154 | do 155 | ./manage.py tenant_command dumpdata --schema=$t --indent=2 auth.user > ${t}_users.json; 156 | done 157 | 158 | 159 | Storage 160 | ------- 161 | 162 | The :mod:`~django.core.files.storage` API will not isolate media per tenant. Your ``MEDIA_ROOT`` will be a shared space between all tenants. 163 | 164 | To avoid this you should configure a tenant aware storage backend - you will be warned if this is not the case. 165 | 166 | .. code-block:: python 167 | 168 | # settings.py 169 | 170 | MEDIA_ROOT = '/data/media' 171 | MEDIA_URL = '/media/' 172 | DEFAULT_FILE_STORAGE = 'tenant_schemas.storage.TenantFileSystemStorage' 173 | 174 | We provide :class:`tenant_schemas.storage.TenantStorageMixin` which can be added to any third-party storage backend. 175 | 176 | 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. 177 | 178 | .. code-block:: text 179 | 180 | # illustrative /etc/nginx/cond.d/tenant.conf 181 | 182 | upstream web { 183 | server localhost:8080 fail_timeout=5s; 184 | } 185 | 186 | server { 187 | listen 80; 188 | server_name ~^(www\.)?(.+)$; 189 | 190 | location / { 191 | proxy_pass http://web; 192 | proxy_redirect off; 193 | proxy_set_header Host $host; 194 | } 195 | 196 | location /media/ { 197 | alias /data/media/$2/; 198 | } 199 | } 200 | 201 | 202 | Utils 203 | ----- 204 | 205 | There are several utils available in `tenant_schemas.utils` that can help you in writing more complicated applications. 206 | 207 | 208 | .. function:: schema_context(schema_name) 209 | 210 | This is a context manager. Database queries performed inside it will be executed in against the passed ``schema_name``. 211 | 212 | .. code-block:: python 213 | 214 | from tenant_schemas.utils import schema_context 215 | 216 | with schema_context(schema_name): 217 | # All comands here are ran under the schema `schema_name` 218 | 219 | # Restores the `SEARCH_PATH` to its original value 220 | 221 | 222 | .. function:: tenant_context(tenant_object) 223 | 224 | This context manager is very similiar to the ``schema_context`` function, 225 | but it takes a tenant model object as the argument instead. 226 | 227 | .. code-block:: python 228 | 229 | from tenant_schemas.utils import tenant_context 230 | 231 | with tenant_context(tenant): 232 | # All commands here are ran under the schema from the `tenant` object 233 | 234 | # Restores the `SEARCH_PATH` to its original value 235 | 236 | 237 | 238 | .. function:: schema_exists(schema_name) 239 | 240 | Returns ``True`` if a schema exists in the current database. 241 | 242 | .. code-block:: python 243 | 244 | from django.core.exceptions import ValidationError 245 | from django.utils.text import slugify 246 | 247 | from tenant_schemas.utils import schema_exists 248 | 249 | class TenantModelForm: 250 | # ... 251 | 252 | def clean_schema_name(self) 253 | schema_name = self.cleaned_data["schema_name"] 254 | schema_name = slugify(schema_name).replace("-", "") 255 | if schema_exists(schema_name): 256 | raise ValidationError("A schema with this name already exists in the database") 257 | else: 258 | return schema_name 259 | 260 | 261 | .. function:: get_tenant_model() 262 | 263 | Returns the class of the tenant model. 264 | 265 | .. function:: get_public_schema_name() 266 | 267 | Returns the name of the public schema (from settings or the default ``public``). 268 | 269 | 270 | .. function:: get_limit_set_calls() 271 | 272 | Returns the ``TENANT_LIMIT_SET_CALLS`` setting or the default (``False``). See below. 273 | 274 | 275 | Signals 276 | ------- 277 | 278 | 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. 279 | 280 | For this purpose, we have provided a ``post_schema_sync`` signal, which is available in ``tenant_schemas.signals`` 281 | 282 | .. code-block:: python 283 | 284 | 285 | from tenant_schemas.signals import post_schema_sync 286 | from tenant_schemas.models import TenantMixin 287 | 288 | def foo_bar(sender, tenant, **kwargs): 289 | ... 290 | #This function will run after the tenant is saved, its schema created and synced. 291 | ... 292 | 293 | post_schema_sync.connect(foo_bar, sender=TenantMixin) 294 | 295 | 296 | Logging 297 | ------- 298 | 299 | The optional ``TenantContextFilter`` can be included in ``settings.LOGGING`` to add the current ``schema_name`` and ``domain_url`` to the logging context. 300 | 301 | .. code-block:: python 302 | 303 | # settings.py 304 | 305 | LOGGING = { 306 | 'filters': { 307 | 'tenant_context': { 308 | '()': 'tenant_schemas.log.TenantContextFilter' 309 | }, 310 | }, 311 | 'formatters': { 312 | 'tenant_context': { 313 | 'format': '[%(schema_name)s:%(domain_url)s] ' 314 | '%(levelname)-7s %(asctime)s %(message)s', 315 | }, 316 | }, 317 | 'handlers': { 318 | 'console': { 319 | 'filters': ['tenant_context'], 320 | }, 321 | }, 322 | } 323 | 324 | This will result in logging output that looks similar to: 325 | 326 | .. code-block:: text 327 | 328 | [example:example.com] DEBUG 13:29 django.db.backends: (0.001) SELECT ... 329 | 330 | 331 | Performance Considerations 332 | -------------------------- 333 | 334 | The hook for ensuring the ``search_path`` is set properly happens inside the ``DatabaseWrapper`` method ``_cursor()``, which sets the path on every database operation. However, in a high volume environment, this can take considerable time. A flag, ``TENANT_LIMIT_SET_CALLS``, is available to keep the number of calls to a minimum. The flag may be set in ``settings.py`` as follows: 335 | 336 | .. code-block:: python 337 | 338 | # settings.py: 339 | 340 | TENANT_LIMIT_SET_CALLS = True 341 | 342 | When set, ``django-pg-tenants`` will set the search path only once per request. The default is ``False``. 343 | 344 | 345 | Third Party Apps 346 | ---------------- 347 | 348 | Celery 349 | ~~~~~~ 350 | 351 | Support for Celery is available at `tenant-schemas-celery `_. 352 | 353 | django-debug-toolbar 354 | ~~~~~~~~~~~~~~~~~~~~ 355 | 356 | `django-debug-toolbar `_ routes need to be added to ``urls.py`` (both public and tenant) manually. 357 | 358 | .. code-block:: python 359 | 360 | from django.conf import settings 361 | from django.conf.urls import include 362 | if settings.DEBUG: 363 | import debug_toolbar 364 | 365 | urlpatterns += patterns( 366 | '', 367 | url(r'^__debug__/', include(debug_toolbar.urls)), 368 | ) 369 | -------------------------------------------------------------------------------- /dpt_test_project/.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | -------------------------------------------------------------------------------- /dpt_test_project/customers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/dpt_test_project/customers/__init__.py -------------------------------------------------------------------------------- /dpt_test_project/customers/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from tenant_schemas.models import TenantMixin 3 | 4 | 5 | class Client(TenantMixin): 6 | name = models.CharField(max_length=100) 7 | description = models.TextField(max_length=200) 8 | created_on = models.DateField(auto_now_add=True) 9 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/dpt_test_project/dpt_test_app/__init__.py -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='DummyModel', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('name', models.CharField(max_length=100)), 20 | ], 21 | options={ 22 | }, 23 | bases=(models.Model,), 24 | ), 25 | migrations.CreateModel( 26 | name='ModelWithFkToPublicUser', 27 | fields=[ 28 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 29 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT)), 30 | ], 31 | options={ 32 | }, 33 | bases=(models.Model,), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/migrations/0002_test_drop_unique.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('dpt_test_app', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='DummyModel', 18 | name='unique_value', 19 | field=models.IntegerField(blank=True, null=True, unique=True), 20 | ), 21 | 22 | migrations.AlterField( 23 | model_name='DummyModel', 24 | name='unique_value', 25 | field=models.IntegerField(blank=True, null=True), 26 | ), 27 | 28 | migrations.RemoveField( 29 | model_name='DummyModel', 30 | name='unique_value', 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/migrations/0003_test_add_db_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('dpt_test_app', '0002_test_drop_unique'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='DummyModel', 17 | name='indexed_value', 18 | field=models.CharField(max_length=255, db_index=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/migrations/0004_test_alter_unique.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('dpt_test_app', '0003_test_add_db_index'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='DummyModel', 17 | name='indexed_value', 18 | field=models.CharField(max_length=255, unique=True), 19 | ), 20 | 21 | migrations.RemoveField( 22 | model_name='DummyModel', 23 | name='indexed_value', 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/dpt_test_project/dpt_test_app/migrations/__init__.py -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | 4 | 5 | class DummyModel(models.Model): 6 | """ 7 | Just a test model so we can test manipulating data inside a tenant 8 | """ 9 | name = models.CharField(max_length=100) 10 | 11 | def __unicode__(self): 12 | return self.name 13 | 14 | 15 | class ModelWithFkToPublicUser(models.Model): 16 | user = models.ForeignKey(User, on_delete=models.PROTECT) 17 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/dpt_test_project/dpt_test_project/__init__.py -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for dts_test_project project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.8/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.8/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | 14 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = "cl1)b#c&xmm36z3e(quna-vb@ab#&gpjtdjtpyzh!qn%bc^xxn" 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | ALLOWED_HOSTS = [] 26 | 27 | DEFAULT_FILE_STORAGE = "tenant_schemas.storage.TenantFileSystemStorage" 28 | 29 | # Application definition 30 | 31 | SHARED_APPS = ( 32 | "tenant_schemas", # mandatory 33 | "customers", # you must list the app where your tenant model resides in 34 | "django.contrib.auth", 35 | "django.contrib.contenttypes", 36 | "django.contrib.sessions", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | ) 40 | 41 | TENANT_APPS = ("dpt_test_app",) 42 | 43 | TENANT_MODEL = "customers.Client" # app.Model 44 | 45 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 46 | 47 | INSTALLED_APPS = ( 48 | "tenant_schemas", 49 | "dpt_test_app", 50 | "customers", 51 | "django.contrib.auth", 52 | "django.contrib.contenttypes", 53 | "django.contrib.sessions", 54 | "django.contrib.messages", 55 | "django.contrib.staticfiles", 56 | ) 57 | 58 | ROOT_URLCONF = "dpt_test_project.urls" 59 | 60 | WSGI_APPLICATION = "dpt_test_project.wsgi.application" 61 | 62 | # Database 63 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 64 | 65 | DATABASES = { 66 | "default": { 67 | "ENGINE": "tenant_schemas.postgresql_backend", 68 | "NAME": os.environ.get("PGNAME", "dpt_test_project"), 69 | "USER": os.environ.get("PGUSER"), 70 | "PASSWORD": os.environ.get("PGPASSWORD"), 71 | "HOST": os.environ.get("PGHOST"), 72 | "PORT": int(os.environ.get("PGPORT")) if os.environ.get("PG PORT") else None, 73 | } 74 | } 75 | 76 | DATABASE_ROUTERS = ("tenant_schemas.routers.TenantSyncRouter",) 77 | 78 | MIDDLEWARE = ( 79 | "tenant_tutorial.middleware.TenantTutorialMiddleware", 80 | "django.middleware.common.CommonMiddleware", 81 | "django.contrib.sessions.middleware.SessionMiddleware", 82 | "django.middleware.csrf.CsrfViewMiddleware", 83 | "django.contrib.auth.middleware.AuthenticationMiddleware", 84 | "django.contrib.messages.middleware.MessageMiddleware", 85 | ) 86 | 87 | TEMPLATES = [ 88 | { 89 | "BACKEND": "django.template.backends.django.DjangoTemplates", 90 | "DIRS": [], 91 | "OPTIONS": { 92 | "debug": True, 93 | "context_processors": ( 94 | "django.core.context_processors.request", 95 | "django.contrib.auth.context_processors.auth", 96 | "django.core.context_processors.debug", 97 | "django.core.context_processors.media", 98 | "django.core.context_processors.static", 99 | "django.contrib.messages.context_processors.messages", 100 | ), 101 | "loaders": ( 102 | "tenant_schemas.template_loaders.FilesystemLoader", 103 | "django.template.loaders.app_directories.Loader", 104 | ), 105 | }, 106 | } 107 | ] 108 | 109 | MULTITENANT_TEMPLATE_DIRS = [] 110 | 111 | # Internationalization 112 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 113 | 114 | LANGUAGE_CODE = "en-us" 115 | 116 | TIME_ZONE = "UTC" 117 | 118 | USE_I18N = True 119 | 120 | USE_L10N = True 121 | 122 | USE_TZ = True 123 | 124 | # Static files (CSS, JavaScript, Images) 125 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 126 | 127 | STATIC_URL = "/static/" 128 | 129 | STATICFILES_STORAGE = "tenant_schemas.storage.TenantStaticFilesStorage" 130 | 131 | LOGGING = { 132 | "version": 1, 133 | "disable_existing_loggers": False, 134 | "filters": { 135 | "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, 136 | "tenant_context": {"()": "tenant_schemas.log.TenantContextFilter"}, 137 | }, 138 | "formatters": { 139 | "simple": {"format": "%(levelname)-7s %(asctime)s %(message)s"}, 140 | "tenant_context": { 141 | "format": "[%(schema_name)s:%(domain_url)s] %(levelname)-7s %(asctime)s %(message)s", 142 | }, 143 | }, 144 | "handlers": { 145 | "null": {"class": "logging.NullHandler"}, 146 | "console": { 147 | "class": "logging.StreamHandler", 148 | "filters": ["tenant_context"], 149 | "formatter": "tenant_context", 150 | }, 151 | }, 152 | "loggers": {"": {"handlers": ["null"], "level": "DEBUG", "propagate": True}}, 153 | } 154 | -------------------------------------------------------------------------------- /dpt_test_project/dpt_test_project/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /dpt_test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpt_test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/customers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/examples/tenant_tutorial/customers/__init__.py -------------------------------------------------------------------------------- /examples/tenant_tutorial/customers/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class GenerateUsersForm(forms.Form): 5 | pass 6 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/customers/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2015-12-28 15:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import tenant_schemas.postgresql_backend.base 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Client', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('domain_url', models.CharField(max_length=128, unique=True)), 22 | ('schema_name', models.CharField(max_length=63, unique=True, validators=[tenant_schemas.postgresql_backend.base._check_schema_name])), 23 | ('name', models.CharField(max_length=100)), 24 | ('description', models.TextField(max_length=200)), 25 | ('created_on', models.DateField(auto_now_add=True)), 26 | ], 27 | options={ 28 | 'abstract': False, 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/customers/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/examples/tenant_tutorial/customers/migrations/__init__.py -------------------------------------------------------------------------------- /examples/tenant_tutorial/customers/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from tenant_schemas.models import TenantMixin 3 | 4 | 5 | class Client(TenantMixin): 6 | name = models.CharField(max_length=100) 7 | description = models.TextField(max_length=200) 8 | created_on = models.DateField(auto_now_add=True) 9 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/customers/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db.utils import DatabaseError 3 | from django.views.generic import FormView 4 | from customers.forms import GenerateUsersForm 5 | from customers.models import Client 6 | from random import choice 7 | 8 | 9 | class TenantView(FormView): 10 | form_class = GenerateUsersForm 11 | template_name = "index_tenant.html" 12 | success_url = "/" 13 | 14 | def get_context_data(self, **kwargs): 15 | context = super(TenantView, self).get_context_data(**kwargs) 16 | context['tenants_list'] = Client.objects.all() 17 | context['users'] = User.objects.all() 18 | return context 19 | 20 | def form_valid(self, form): 21 | User.objects.all().delete() # clean current users 22 | 23 | # generate five random users 24 | USERS_TO_GENERATE = 5 25 | first_names = ["Aiden", "Jackson", "Ethan", "Liam", "Mason", "Noah", 26 | "Lucas", "Jacob", "Jayden", "Jack", "Sophia", "Emma", 27 | "Olivia", "Isabella", "Ava", "Lily", "Zoe", "Chloe", 28 | "Mia", "Madison"] 29 | last_names = ["Smith", "Brown", "Lee ", "Wilson", "Martin", "Patel", 30 | "Taylor", "Wong", "Campbell", "Williams"] 31 | 32 | while User.objects.count() != USERS_TO_GENERATE: 33 | first_name = choice(first_names) 34 | last_name = choice(last_names) 35 | try: 36 | user = User(username=(first_name + last_name).lower(), 37 | email="%s@%s.com" % (first_name, last_name), 38 | first_name=first_name, 39 | last_name=last_name) 40 | user.save() 41 | except DatabaseError: 42 | pass 43 | 44 | return super(TenantView, self).form_valid(form) 45 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tenant_tutorial.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 28 | 29 | 30 | 31 |
32 | {% block summary %} 33 | {% endblock %} 34 |
35 | 36 |
37 | {% block instructions %} 38 | {% endblock %} 39 |
40 | 41 | 58 | 59 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/templates/index_public.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block title %}Tenant Tutorial{% endblock %} 3 | 4 | {% block summary %} 5 |

Welcome to the Tenant Tutorial!

6 |

This interactive tutorial will teach you how to use django-pg-tenants.

7 | {% endblock %} 8 | 9 | {% block instructions %} 10 | {% if need_sync %} 11 |

First Step: Sync your database

12 |

Your database is empty, so the first step is to sync it. We only want to sync the SHARED_APPS. 13 | For your convenience, here's the contents of SHARED_APPS:

14 |
    15 | {% for app in shared_apps %} 16 |
  • {{ app }}
  • 17 | {% endfor %} 18 |

19 |

Just run the command below on your shell to sync SHARED_APPS. Make sure your environment 20 | has Django and django-pg-tenants available.

21 |
$ python manage.py migrate_schemas --shared
22 |

When you're done refresh this page.

23 | {% elif no_public_tenant %} 24 |

Second Step: Create a public tenant

25 |

So how does django-pg-tenants work?

26 |

django-pg-tenants uses the request's hostname to try to find a tenant. 27 | When a match is found, 28 | PostgreSQL's search path 29 | is automatically set to be this tenant.

30 |
31 |

For this request, django-pg-tenants couldn't find any tenant for the current address ({{ hostname }}). 32 | When no tenant is found, django-pg-tenants normally returns a 404, but since 33 | this is a tutorial and no tenant exists yet, we let it proceed. 34 |

Recommended Tenant's URLs Structure

35 |

Let's assume you have your main website at trendy-sass.com. The recommended structure is 36 | to put your tenants at subdomains, like tenant1.trendy-sass.com, 37 | tenant2.trendy-sass.com and so forth.

38 |

Creating the public tenant

39 |

django-pg-tenants requires a tenant for all addresses you use, including your main website, 40 | which we will from now on refer to as the public tenant.

41 |
42 |

Our model is called Customer and looks like this (taken from models.py):

43 |
 44 | class Client(TenantMixin):
 45 |     name = models.CharField(max_length=100)
 46 |     description = models.TextField(max_length=200)
 47 |     created_on = models.DateField(auto_now_add=True)
48 |

Let's create a tenant for our main website, located at {{ hostname }}. Open up a shell and enter the django shell:

49 |
$ ./manage.py shell
50 |

To create a tenant run the following commands:

51 |
from customers.models import Client
52 |
 53 | Client(domain_url='{{ hostname }}',
 54 |     schema_name='public',
 55 |     name='Trendy SaSS',
 56 |     description='Public Tenant').save()
57 |

Done! django-pg-tenants will now be able to locate our public tenant and won't return 404. Refresh this page to see the next step.

58 | {% elif only_public_tenant %} 59 |

Third Step: Create Tenants

60 |

We've already created the public tenant, now it's time to create some tenants for subdomains. I assume you're running this on your local machine, 61 | so the easiest way to simulate domains is to edit your hosts file. 62 | Here are instructions for all platforms. 63 | I'll assume you're on Linux.

64 |
$ sudo nano /etc/hosts 
65 |

Add the following lines:

66 |
 67 | 127.0.0.1	tenant1.trendy-sass.com
 68 | 127.0.0.1	tenant2.trendy-sass.com
69 |

We're basically tricking our computer to think both tenant1.trendy-sass.com and tenant2.trendy-sass.com point to 127.0.0.1. 70 | Once you're done, try visiting tenant1.trendy-sass.com, 71 | you should get a django 404. As we have previously mentioned, we don't have a tenant there yet, so a 404 will be thrown.

72 |
73 |

We can now add tenants using these URLs and our project will be able to find them and identify them as our tenants. Back to the django shell:

74 | 75 |
$ ./manage.py shell
76 |
from customers.models import Client
77 |
 78 | Client(domain_url='tenant1.trendy-sass.com',
 79 |     schema_name='tenant1',
 80 |     name='Tenant1 - Awesome',
 81 |     description='Our first real tenant, awesome!').save()
82 |

Saving a tenant that didn't exist before will create their schema and sync TENANT_APPS automatically. You should see 83 | the following lines as the result.

84 | {% if DJANGO17 %}
Operations to perform:
 85 |   Synchronize unmigrated apps: customers, tenant_schemas
 86 |   Apply all migrations: contenttypes, auth, sessions
 87 | Synchronizing apps without migrations:
 88 |   Creating tables...
 89 |   Installing custom SQL...
 90 |   Installing indexes...
 91 | Running migrations:
 92 |   Applying contenttypes.0001_initial... OK
 93 |   Applying auth.0001_initial... OK
 94 |   Applying sessions.0001_initial... OK
95 | {% else %}
=== Running syncdb for schema: tenant1
 96 | Creating tables ...
 97 | Creating table auth_permission
 98 | Creating table auth_group_permissions
 99 | Creating table auth_group
100 | Creating table auth_user_groups
101 | Creating table auth_user_user_permissions
102 | Creating table auth_user
103 | Creating table django_content_type
104 | Installing custom SQL ...
105 | Installing indexes ...
106 | Installed 0 object(s) from 0 fixture(s)
107 | {% endif %} 108 |

This means your tenant was installed successfully. Now create the second tenant.

109 |
110 | Client(domain_url='tenant2.trendy-sass.com',
111 |     schema_name='tenant2',
112 |     name='Tenant2 - Even Awesome-r',
113 |     description='A second tenant, even more awesome!').save()
114 | 115 |

Now try visiting tenant1.trendy-sass.com and 116 | tenant2.trendy-sass.com or refresh this page.

117 | {% else %} 118 |

Tutorial Complete!

119 |

Well done, you have completed the tutorial! Use the bottom menu to see your tenants.

120 |

Where to go from here

121 |

There are some interesting features that we did not cover.

122 | 126 | {% endif %} 127 | {% endblock %} 128 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/templates/index_tenant.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block title %}{{ request.tenant.name }}{% endblock %} 3 | 4 | {% block summary %} 5 |

{{ request.tenant.name }}

6 |

{{ request.tenant.description }}

7 | {% endblock %} 8 | 9 | {% block instructions %} 10 |

Each tenant is on a separate schema and contains different data. Take a look at the users list of this tenant.

11 |

Users List

12 | {% if users %} 13 |
    14 | {% for user in users %} 15 |
  • {{ user.first_name }} {{ user.last_name }}
  • 16 | {% endfor %} 17 |
18 | {% else %} 19 |

You don't have any users!

20 | {% endif %} 21 |

Generate Random Users

22 |

Click the button below to generate five random users.


23 |
{% csrf_token %} 24 | {{ form.as_p }} 25 | 26 |
27 | {% endblock %} -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/examples/tenant_tutorial/tenant_tutorial/__init__.py -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import connection 4 | from django.http import Http404 5 | from tenant_schemas.utils import get_tenant_model, remove_www_and_dev, get_public_schema_name 6 | from django.db import utils 7 | 8 | 9 | class TenantTutorialMiddleware(object): 10 | def process_request(self, request): 11 | connection.set_schema_to_public() 12 | hostname_without_port = remove_www_and_dev(request.get_host().split(':')[0]) 13 | 14 | TenantModel = get_tenant_model() 15 | 16 | try: 17 | request.tenant = TenantModel.objects.get(domain_url=hostname_without_port) 18 | except utils.DatabaseError: 19 | request.urlconf = settings.PUBLIC_SCHEMA_URLCONF 20 | return 21 | except TenantModel.DoesNotExist: 22 | if hostname_without_port in ("127.0.0.1", "localhost"): 23 | request.urlconf = settings.PUBLIC_SCHEMA_URLCONF 24 | return 25 | else: 26 | raise Http404 27 | 28 | connection.set_tenant(request.tenant) 29 | ContentType.objects.clear_cache() 30 | 31 | if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF') and request.tenant.schema_name == get_public_schema_name(): 32 | request.urlconf = settings.PUBLIC_SCHEMA_URLCONF 33 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Django settings for tenant_tutorial project. 4 | 5 | DEBUG = True 6 | 7 | ADMINS = ( 8 | # ('Your Name', 'your_email@example.com'), 9 | ) 10 | 11 | MANAGERS = ADMINS 12 | 13 | DATABASES = { 14 | "default": { 15 | "ENGINE": "tenant_schemas.postgresql_backend", # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 16 | "NAME": "tenant_tutorial", # Or path to database file if using sqlite3. 17 | "USER": "postgres", 18 | "PASSWORD": "root", 19 | "HOST": "localhost", # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. 20 | "PORT": "", # Set to empty string for default. 21 | } 22 | } 23 | 24 | # Hosts/domain names that are valid for this site; required if DEBUG is False 25 | # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts 26 | ALLOWED_HOSTS = ["localhost", ".trendy-sass.com"] 27 | 28 | # Local time zone for this installation. Choices can be found here: 29 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 30 | # although not all choices may be available on all operating systems. 31 | # In a Windows environment this must be set to your system time zone. 32 | TIME_ZONE = "America/Chicago" 33 | 34 | # Language code for this installation. All choices can be found here: 35 | # http://www.i18nguy.com/unicode/language-identifiers.html 36 | LANGUAGE_CODE = "en-us" 37 | 38 | SITE_ID = 1 39 | 40 | # If you set this to False, Django will make some optimizations so as not 41 | # to load the internationalization machinery. 42 | USE_I18N = True 43 | 44 | # If you set this to False, Django will not format dates, numbers and 45 | # calendars according to the current locale. 46 | USE_L10N = True 47 | 48 | # If you set this to False, Django will not use timezone-aware datetimes. 49 | USE_TZ = True 50 | 51 | # Absolute filesystem path to the directory that will hold user-uploaded files. 52 | # Example: "/var/www/example.com/media/" 53 | MEDIA_ROOT = "" 54 | 55 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 56 | # trailing slash. 57 | # Examples: "http://example.com/media/", "http://media.example.com/" 58 | MEDIA_URL = "" 59 | 60 | # Absolute path to the directory static files should be collected to. 61 | # Don't put anything in this directory yourself; store your static files 62 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 63 | # Example: "/var/www/example.com/static/" 64 | STATIC_ROOT = "" 65 | 66 | # URL prefix for static files. 67 | # Example: "http://example.com/static/", "http://static.example.com/" 68 | STATIC_URL = "/static/" 69 | 70 | # Additional locations of static files 71 | STATICFILES_DIRS = ( 72 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 73 | # Always use forward slashes, even on Windows. 74 | # Don't forget to use absolute paths, not relative paths. 75 | ) 76 | 77 | # List of finder classes that know how to find static files in 78 | # various locations. 79 | STATICFILES_FINDERS = ( 80 | "django.contrib.staticfiles.finders.FileSystemFinder", 81 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 82 | ) 83 | 84 | # Make this unique, and don't share it with anybody. 85 | SECRET_KEY = "as-%*_93v=r5*p_7cu8-%o6b&x^g+q$#*e*fl)k)x0-t=%q0qa" 86 | 87 | 88 | DATABASE_ROUTERS = ("tenant_schemas.routers.TenantSyncRouter",) 89 | 90 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 91 | 92 | MIDDLEWARE = ( 93 | "tenant_tutorial.middleware.TenantTutorialMiddleware", 94 | "django.middleware.common.CommonMiddleware", 95 | "django.contrib.sessions.middleware.SessionMiddleware", 96 | "django.middleware.csrf.CsrfViewMiddleware", 97 | "django.contrib.auth.middleware.AuthenticationMiddleware", 98 | "django.contrib.messages.middleware.MessageMiddleware", 99 | # Uncomment the next line for simple clickjacking protection: 100 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 101 | ) 102 | 103 | ROOT_URLCONF = "tenant_tutorial.urls_tenants" 104 | PUBLIC_SCHEMA_URLCONF = "tenant_tutorial.urls_public" 105 | 106 | # Python dotted path to the WSGI application used by Django's runserver. 107 | WSGI_APPLICATION = "tenant_tutorial.wsgi.application" 108 | 109 | TEMPLATES = [ 110 | { 111 | "BACKEND": "django.template.backends.django.DjangoTemplates", 112 | "DIRS": [ 113 | os.path.join(os.path.dirname(__file__), "..", "templates").replace( 114 | "\\", "/" 115 | ), 116 | ], 117 | "APP_DIRS": False, 118 | "OPTIONS": { 119 | "debug": DEBUG, 120 | "context_processors": [ 121 | "django.template.context_processors.debug", 122 | "django.template.context_processors.request", 123 | "django.contrib.auth.context_processors.auth", 124 | "django.contrib.messages.context_processors.messages", 125 | ], 126 | "loaders": [ 127 | "tenant_schemas.template_loaders.FilesystemLoader", 128 | "django.template.loaders.app_directories.Loader", 129 | ], 130 | }, 131 | }, 132 | ] 133 | 134 | MULTITENANT_TEMPLATE_DIRS = [] 135 | 136 | SHARED_APPS = ( 137 | "tenant_schemas", # mandatory 138 | "customers", # you must list the app where your tenant model resides in 139 | "django.contrib.auth", 140 | "django.contrib.contenttypes", 141 | "django.contrib.sessions", 142 | "django.contrib.messages", 143 | "django.contrib.staticfiles", 144 | ) 145 | 146 | TENANT_APPS = ( 147 | # The following Django contrib apps must be in TENANT_APPS 148 | "django.contrib.contenttypes", 149 | "django.contrib.auth", 150 | ) 151 | 152 | TENANT_MODEL = "customers.Client" # app.Model 153 | 154 | DEFAULT_FILE_STORAGE = "tenant_schemas.storage.TenantFileSystemStorage" 155 | 156 | INSTALLED_APPS = ( 157 | "tenant_schemas", 158 | "customers", 159 | "django.contrib.auth", 160 | "django.contrib.contenttypes", 161 | "django.contrib.sessions", 162 | "django.contrib.messages", 163 | "django.contrib.staticfiles", 164 | ) 165 | 166 | SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer" 167 | 168 | # A sample logging configuration. The only tangible logging 169 | # performed by this configuration is to send an email to 170 | # the site admins on every HTTP 500 error when DEBUG=False. 171 | # See http://docs.djangoproject.com/en/dev/topics/logging for 172 | # more details on how to customize your logging configuration. 173 | LOGGING = { 174 | "version": 1, 175 | "disable_existing_loggers": False, 176 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 177 | "handlers": { 178 | "mail_admins": { 179 | "level": "ERROR", 180 | "filters": ["require_debug_false"], 181 | "class": "django.utils.log.AdminEmailHandler", 182 | } 183 | }, 184 | "loggers": { 185 | "django.request": { 186 | "handlers": ["mail_admins"], 187 | "level": "ERROR", 188 | "propagate": True, 189 | }, 190 | }, 191 | } 192 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/urls_public.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from tenant_tutorial.views import HomeView 3 | 4 | 5 | urlpatterns = [ 6 | url(r'^$', HomeView.as_view()), 7 | ] 8 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/urls_tenants.py: -------------------------------------------------------------------------------- 1 | from customers.views import TenantView 2 | from django.conf.urls import url 3 | 4 | urlpatterns = [ 5 | url(r'^$', TenantView.as_view()), 6 | ] 7 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/views.py: -------------------------------------------------------------------------------- 1 | from customers.models import Client 2 | from django.conf import settings 3 | from django.db import utils 4 | from django.views.generic import TemplateView 5 | 6 | from tenant_schemas.utils import remove_www 7 | 8 | 9 | class HomeView(TemplateView): 10 | template_name = "index_public.html" 11 | 12 | def get_context_data(self, **kwargs): 13 | context = super(HomeView, self).get_context_data(**kwargs) 14 | 15 | hostname_without_port = remove_www(self.request.get_host().split(':')[0]) 16 | 17 | try: 18 | Client.objects.get(schema_name='public') 19 | except utils.DatabaseError: 20 | context['need_sync'] = True 21 | context['shared_apps'] = settings.SHARED_APPS 22 | context['tenants_list'] = [] 23 | return context 24 | except Client.DoesNotExist: 25 | context['no_public_tenant'] = True 26 | context['hostname'] = hostname_without_port 27 | 28 | if Client.objects.count() == 1: 29 | context['only_public_tenant'] = True 30 | 31 | context['tenants_list'] = Client.objects.all() 32 | return context 33 | -------------------------------------------------------------------------------- /examples/tenant_tutorial/tenant_tutorial/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for tenant_tutorial project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | from django.core.wsgi import get_wsgi_application 18 | 19 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 20 | # if running multiple sites in the same mod_wsgi process. To fix this, use 21 | # mod_wsgi daemon mode with each site in its own daemon process, or use 22 | # os.environ["DJANGO_SETTINGS_MODULE"] = "tenant_tutorial.settings" 23 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tenant_tutorial.settings") 24 | 25 | # This application object is used by any WSGI server configured to use this 26 | # file. This includes Django's development server, if the WSGI_APPLICATION 27 | # setting points here. 28 | application = get_wsgi_application() 29 | 30 | # Apply WSGI middleware here. 31 | # from helloworld.wsgi import HelloWorldApplication 32 | # application = HelloWorldApplication(application) 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | 4 | [isort] 5 | combine_as_imports = true 6 | default_section = THIRDPARTY 7 | include_trailing_comma = true 8 | multi_line_output = 5 9 | not_skip = __init__.py 10 | 11 | [flake8] 12 | exclude = .tox,docs,build,migrations,__init__.py 13 | ignore = C901,E501,E731,W503 14 | 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import exists 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name="django-pg-tenants", 7 | author="Vinta Software", 8 | author_email="contact@vinta.com.br", 9 | packages=[ 10 | "tenant_schemas", 11 | "tenant_schemas.migration_executors", 12 | "tenant_schemas.postgresql_backend", 13 | "tenant_schemas.management", 14 | "tenant_schemas.management.commands", 15 | "tenant_schemas.templatetags", 16 | "tenant_schemas.test", 17 | "tenant_schemas.tests", 18 | ], 19 | scripts=[], 20 | url="https://github.com/vintasoftware/django-pg-tenants", 21 | license="MIT", 22 | description="Tenant support for Django using PostgreSQL schemas.", 23 | long_description=open("README.rst").read() if exists("README.rst") else "", 24 | classifiers=[ 25 | "License :: OSI Approved :: MIT License", 26 | "Framework :: Django", 27 | "Framework :: Django :: 2.2", 28 | "Framework :: Django :: 3.1", 29 | "Framework :: Django :: 3.2", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3.6", 32 | "Programming Language :: Python :: 3.7", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Topic :: Database", 36 | "Topic :: Software Development :: Libraries", 37 | ], 38 | install_requires=["Django>=2.2", "ordered-set", "psycopg2-binary", "six"], 39 | setup_requires=["setuptools-scm"], 40 | use_scm_version=True, 41 | zip_safe=False, 42 | ) 43 | -------------------------------------------------------------------------------- /tenant_schemas/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'tenant_schemas.apps.TenantSchemaConfig' 2 | -------------------------------------------------------------------------------- /tenant_schemas/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.conf import settings 3 | from django.core.checks import Critical, Error, Warning, register 4 | from django.core.files.storage import default_storage 5 | from tenant_schemas.storage import TenantStorageMixin 6 | from tenant_schemas.utils import get_public_schema_name, get_tenant_model 7 | 8 | 9 | class TenantSchemaConfig(AppConfig): 10 | name = 'tenant_schemas' 11 | 12 | 13 | @register('config') 14 | def best_practice(app_configs, **kwargs): 15 | """ 16 | Test for configuration recommendations. These are best practices, they 17 | avoid hard to find bugs and unexpected behaviour. 18 | """ 19 | if app_configs is None: 20 | app_configs = apps.get_app_configs() 21 | 22 | # Take the app_configs and turn them into *old style* application names. 23 | # This is what we expect in the SHARED_APPS and TENANT_APPS settings. 24 | INSTALLED_APPS = [ 25 | config.name 26 | for config in app_configs 27 | ] 28 | 29 | if not hasattr(settings, 'TENANT_APPS'): 30 | return [Critical('TENANT_APPS setting not set')] 31 | 32 | if not hasattr(settings, 'TENANT_MODEL'): 33 | return [Critical('TENANT_MODEL setting not set')] 34 | 35 | if not hasattr(settings, 'SHARED_APPS'): 36 | return [Critical('SHARED_APPS setting not set')] 37 | 38 | if 'tenant_schemas.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS: 39 | return [ 40 | Critical("DATABASE_ROUTERS setting must contain " 41 | "'tenant_schemas.routers.TenantSyncRouter'.") 42 | ] 43 | 44 | errors = [] 45 | 46 | django_index = next(i for i, s in enumerate(INSTALLED_APPS) if s.startswith('django.')) 47 | if INSTALLED_APPS.index('tenant_schemas') > django_index: 48 | errors.append( 49 | Warning("You should put 'tenant_schemas' before any django " 50 | "core applications in INSTALLED_APPS.", 51 | obj="django.conf.settings", 52 | hint="This is necessary to overwrite built-in django " 53 | "management commands with their schema-aware " 54 | "implementations.", 55 | id="tenant_schemas.W001")) 56 | 57 | if not settings.TENANT_APPS: 58 | errors.append( 59 | Error("TENANT_APPS is empty.", 60 | hint="Maybe you don't need this app?", 61 | id="tenant_schemas.E001")) 62 | 63 | if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): 64 | if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: 65 | errors.append(Critical( 66 | "%s can not be included on PG_EXTRA_SEARCH_PATHS." 67 | % get_public_schema_name())) 68 | 69 | # make sure no tenant schema is in settings.PG_EXTRA_SEARCH_PATHS 70 | invalid_schemas = set(settings.PG_EXTRA_SEARCH_PATHS).intersection( 71 | get_tenant_model().objects.all().values_list('schema_name', flat=True)) 72 | if invalid_schemas: 73 | errors.append(Critical( 74 | "Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS." 75 | % ", ".join(sorted(invalid_schemas)))) 76 | 77 | if not settings.SHARED_APPS: 78 | errors.append( 79 | Warning("SHARED_APPS is empty.", 80 | id="tenant_schemas.W002")) 81 | 82 | if not set(settings.TENANT_APPS).issubset(INSTALLED_APPS): 83 | delta = set(settings.TENANT_APPS).difference(INSTALLED_APPS) 84 | errors.append( 85 | Error("You have TENANT_APPS that are not in INSTALLED_APPS", 86 | hint=[a for a in settings.TENANT_APPS if a in delta], 87 | id="tenant_schemas.E002")) 88 | 89 | if not set(settings.SHARED_APPS).issubset(INSTALLED_APPS): 90 | delta = set(settings.SHARED_APPS).difference(INSTALLED_APPS) 91 | errors.append( 92 | Error("You have SHARED_APPS that are not in INSTALLED_APPS", 93 | hint=[a for a in settings.SHARED_APPS if a in delta], 94 | id="tenant_schemas.E003")) 95 | 96 | if not isinstance(default_storage, TenantStorageMixin): 97 | errors.append(Warning( 98 | "Your default storage engine is not tenant aware.", 99 | hint="Set settings.DEFAULT_FILE_STORAGE to " 100 | "'tenant_schemas.storage.TenantFileSystemStorage'", 101 | id="tenant_schemas.W003" 102 | )) 103 | 104 | return errors 105 | -------------------------------------------------------------------------------- /tenant_schemas/cache.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | 3 | 4 | def make_key(key, key_prefix, version): 5 | """ 6 | Tenant aware function to generate a cache key. 7 | 8 | Constructs the key used by all other methods. Prepends the tenant 9 | `schema_name` and `key_prefix'. 10 | """ 11 | return '%s:%s:%s:%s' % (connection.schema_name, key_prefix, version, key) 12 | 13 | 14 | def reverse_key(key): 15 | """ 16 | Tenant aware function to reverse a cache key. 17 | 18 | Required for django-redis REVERSE_KEY_FUNCTION setting. 19 | """ 20 | return key.split(':', 3)[3] 21 | -------------------------------------------------------------------------------- /tenant_schemas/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import connection 4 | 5 | 6 | class TenantContextFilter(logging.Filter): 7 | """ 8 | Add the current ``schema_name`` and ``domain_url`` to log records. 9 | 10 | Thanks to @regolith for the snippet on #248 11 | """ 12 | def filter(self, record): 13 | record.schema_name = connection.tenant.schema_name 14 | record.domain_url = getattr(connection.tenant, 'domain_url', '') 15 | return True 16 | -------------------------------------------------------------------------------- /tenant_schemas/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/tenant_schemas/management/__init__.py -------------------------------------------------------------------------------- /tenant_schemas/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.conf import settings 3 | from django.core.management import ( 4 | call_command, 5 | get_commands, 6 | load_command_class, 7 | ) 8 | from django.core.management.base import BaseCommand, CommandError 9 | from django.db import connection 10 | from six.moves import input 11 | from tenant_schemas.utils import get_public_schema_name, get_tenant_model 12 | 13 | 14 | class BaseTenantCommand(BaseCommand): 15 | """ 16 | Generic command class useful for iterating any existing command 17 | over all schemata. The actual command name is expected in the 18 | class variable COMMAND_NAME of the subclass. 19 | """ 20 | 21 | def __new__(cls, *args, **kwargs): 22 | """ 23 | Sets option_list and help dynamically. 24 | """ 25 | obj = super(BaseTenantCommand, cls).__new__(cls, *args, **kwargs) 26 | 27 | app_name = get_commands()[obj.COMMAND_NAME] 28 | if isinstance(app_name, BaseCommand): 29 | # If the command is already loaded, use it directly. 30 | obj._original_command = app_name 31 | else: 32 | obj._original_command = load_command_class(app_name, obj.COMMAND_NAME) 33 | 34 | # prepend the command's original help with the info about schemata 35 | # iteration 36 | obj.help = ( 37 | "Calls {cmd} for all registered schemata. You can use regular " 38 | "{cmd} options.\n\nOriginal help for {cmd}:\n\n{help}".format( 39 | cmd=obj.COMMAND_NAME, 40 | help=getattr(obj._original_command, "help", "none"), 41 | ) 42 | ) 43 | 44 | return obj 45 | 46 | def add_arguments(self, parser): 47 | super(BaseTenantCommand, self).add_arguments(parser) 48 | parser.add_argument("-s", "--schema", dest="schema_name") 49 | parser.add_argument( 50 | "-p", 51 | "--skip-public", 52 | dest="skip_public", 53 | action="store_true", 54 | default=False, 55 | ) 56 | # use the privately held reference to the underlying command to invoke 57 | # the add_arguments path on this parser instance 58 | self._original_command.add_arguments(parser) 59 | 60 | def execute_command(self, tenant, command_name, *args, **options): 61 | verbosity = int(options.get("verbosity")) 62 | 63 | if verbosity >= 1: 64 | print() 65 | print( 66 | self.style.NOTICE("=== Switching to schema '") 67 | + self.style.SQL_TABLE(tenant.schema_name) 68 | + self.style.NOTICE("' then calling %s:" % command_name) 69 | ) 70 | 71 | connection.set_tenant(tenant) 72 | 73 | # call the original command with the args it knows 74 | call_command(command_name, *args, **options) 75 | 76 | def handle(self, *args, **options): 77 | """ 78 | Iterates a command over all registered schemata. 79 | """ 80 | if options["schema_name"]: 81 | # only run on a particular schema 82 | connection.set_schema_to_public() 83 | self.execute_command( 84 | get_tenant_model().objects.get(schema_name=options["schema_name"]), 85 | self.COMMAND_NAME, 86 | *args, 87 | **options 88 | ) 89 | else: 90 | for tenant in get_tenant_model().objects.all(): 91 | if not ( 92 | options["skip_public"] 93 | and tenant.schema_name == get_public_schema_name() 94 | ): 95 | self.execute_command(tenant, self.COMMAND_NAME, *args, **options) 96 | 97 | 98 | class InteractiveTenantOption(object): 99 | def add_arguments(self, parser): 100 | parser.add_argument("command") 101 | parser.add_argument( 102 | "-s", "--schema", dest="schema_name", help="specify tenant schema" 103 | ) 104 | 105 | def get_tenant_from_options_or_interactive(self, **options): 106 | TenantModel = get_tenant_model() 107 | all_tenants = TenantModel.objects.all() 108 | 109 | if not all_tenants: 110 | raise CommandError( 111 | """There are no tenants in the system. 112 | To learn how create a tenant, see: 113 | https://django-pg-tenants.readthedocs.io/en/latest/use.html#creating-a-tenant""" 114 | ) 115 | 116 | if options.get("schema_name"): 117 | tenant_schema = options["schema_name"] 118 | else: 119 | while True: 120 | tenant_schema = input("Enter Tenant Schema ('?' to list schemas): ") 121 | if tenant_schema == "?": 122 | print( 123 | "\n".join( 124 | [ 125 | "%s - %s" % (t.schema_name, t.domain_url,) 126 | for t in all_tenants 127 | ] 128 | ) 129 | ) 130 | else: 131 | break 132 | 133 | if tenant_schema not in [t.schema_name for t in all_tenants]: 134 | raise CommandError("Invalid tenant schema, '%s'" % (tenant_schema,)) 135 | 136 | return TenantModel.objects.get(schema_name=tenant_schema) 137 | 138 | 139 | class TenantWrappedCommand(InteractiveTenantOption, BaseCommand): 140 | """ 141 | Generic command class useful for running any existing command 142 | on a particular tenant. The actual command name is expected in the 143 | class variable COMMAND_NAME of the subclass. 144 | """ 145 | 146 | def __new__(cls, *args, **kwargs): 147 | obj = super(TenantWrappedCommand, cls).__new__(cls, *args, **kwargs) 148 | obj.command_instance = obj.COMMAND() 149 | return obj 150 | 151 | def add_arguments(self, parser): 152 | super(TenantWrappedCommand, self).add_arguments(parser) 153 | self.command_instance.add_arguments(parser) 154 | 155 | def handle(self, *args, **options): 156 | tenant = self.get_tenant_from_options_or_interactive(**options) 157 | connection.set_tenant(tenant) 158 | 159 | self.command_instance.execute(*args, **options) 160 | 161 | 162 | class SyncCommon(BaseCommand): 163 | def add_arguments(self, parser): 164 | parser.add_argument( 165 | "--tenant", 166 | action="store_true", 167 | dest="tenant", 168 | default=False, 169 | help="Tells Django to populate only tenant applications.", 170 | ) 171 | parser.add_argument( 172 | "--shared", 173 | action="store_true", 174 | dest="shared", 175 | default=False, 176 | help="Tells Django to populate only shared applications.", 177 | ) 178 | parser.add_argument( 179 | "--app_label", 180 | action="store", 181 | dest="app_label", 182 | nargs="?", 183 | help="App label of an application to synchronize the state.", 184 | ) 185 | parser.add_argument( 186 | "--migration_name", 187 | action="store", 188 | dest="migration_name", 189 | nargs="?", 190 | help=( 191 | "Database state will be brought to the state after that " 192 | 'migration. Use the name "zero" to unapply all migrations.' 193 | ), 194 | ) 195 | parser.add_argument("-s", "--schema", dest="schema_name") 196 | parser.add_argument( 197 | "--executor", 198 | action="store", 199 | dest="executor", 200 | default=None, 201 | help="Executor for running migrations [standard (default)|parallel]", 202 | ) 203 | 204 | def handle(self, *args, **options): 205 | self.sync_tenant = options.get("tenant") 206 | self.sync_public = options.get("shared") 207 | self.schema_name = options.get("schema_name") 208 | self.executor = options.get("executor") 209 | self.installed_apps = settings.INSTALLED_APPS 210 | self.args = args 211 | self.options = options 212 | 213 | if self.schema_name: 214 | if self.sync_public: 215 | raise CommandError( 216 | "schema should only be used with the --tenant switch." 217 | ) 218 | elif self.schema_name == get_public_schema_name(): 219 | self.sync_public = True 220 | else: 221 | self.sync_tenant = True 222 | elif not self.sync_public and not self.sync_tenant: 223 | # no options set, sync both 224 | self.sync_tenant = True 225 | self.sync_public = True 226 | 227 | if hasattr(settings, "TENANT_APPS"): 228 | self.tenant_apps = settings.TENANT_APPS 229 | if hasattr(settings, "SHARED_APPS"): 230 | self.shared_apps = settings.SHARED_APPS 231 | 232 | def _notice(self, output): 233 | if int(self.options.get("verbosity", 1)) >= 1: 234 | self.stdout.write(self.style.NOTICE(output)) 235 | -------------------------------------------------------------------------------- /tenant_schemas/management/commands/collectstatic_schemas.py: -------------------------------------------------------------------------------- 1 | from tenant_schemas.management.commands import BaseTenantCommand 2 | 3 | 4 | class Command(BaseTenantCommand): 5 | COMMAND_NAME = 'collectstatic' 6 | -------------------------------------------------------------------------------- /tenant_schemas/management/commands/list_tenants.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import sys 3 | 4 | from django.core.management import BaseCommand 5 | from tenant_schemas.utils import get_tenant_model 6 | 7 | 8 | class Command(BaseCommand): 9 | def handle(self, *args, **options): 10 | columns = ('schema_name', 'domain_url') 11 | 12 | TenantModel = get_tenant_model() 13 | all_tenants = TenantModel.objects.values_list(*columns) 14 | 15 | out = csv.writer(sys.stdout, dialect=csv.excel_tab) 16 | for tenant in all_tenants: 17 | out.writerow(tenant) 18 | -------------------------------------------------------------------------------- /tenant_schemas/management/commands/migrate.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import CommandError, BaseCommand 3 | 4 | from tenant_schemas.management.commands.migrate_schemas import Command as MigrateSchemasCommand 5 | from tenant_schemas.utils import django_is_in_test_mode 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | def handle(self, *args, **options): 11 | database = options.get('database', 'default') 12 | if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend'): 13 | raise CommandError("migrate has been disabled, for database '{0}'. Use migrate_schemas " 14 | "instead. Please read the documentation if you don't know why you " 15 | "shouldn't call migrate directly!".format(database)) 16 | super(Command, self).handle(*args, **options) 17 | 18 | 19 | if django_is_in_test_mode(): 20 | Command = MigrateSchemasCommand 21 | -------------------------------------------------------------------------------- /tenant_schemas/management/commands/migrate_schemas.py: -------------------------------------------------------------------------------- 1 | from django.core.management.commands.migrate import Command as MigrateCommand 2 | from django.db.migrations.exceptions import MigrationSchemaMissing 3 | from tenant_schemas.management.commands import SyncCommon 4 | from tenant_schemas.migration_executors import get_executor 5 | from tenant_schemas.utils import ( 6 | get_public_schema_name, 7 | get_tenant_model, 8 | schema_exists, 9 | ) 10 | 11 | 12 | class Command(SyncCommon): 13 | requires_system_checks = [] 14 | help = ( 15 | "Updates database schema. Manages both apps with migrations and those without." 16 | ) 17 | 18 | def add_arguments(self, parser): 19 | super(Command, self).add_arguments(parser) 20 | command = MigrateCommand() 21 | command.add_arguments(parser) 22 | 23 | def handle(self, *args, **options): 24 | super(Command, self).handle(*args, **options) 25 | self.PUBLIC_SCHEMA_NAME = get_public_schema_name() 26 | 27 | executor = get_executor(codename=self.executor)(self.args, self.options) 28 | 29 | if self.sync_public and not self.schema_name: 30 | self.schema_name = self.PUBLIC_SCHEMA_NAME 31 | 32 | if self.sync_public: 33 | executor.run_migrations(tenants=[self.schema_name]) 34 | if self.sync_tenant: 35 | if self.schema_name and self.schema_name != self.PUBLIC_SCHEMA_NAME: 36 | if not schema_exists(self.schema_name): 37 | raise MigrationSchemaMissing( 38 | 'Schema "{}" does not exist'.format(self.schema_name) 39 | ) 40 | else: 41 | tenants = [self.schema_name] 42 | else: 43 | tenants = ( 44 | get_tenant_model() 45 | .objects.exclude(schema_name=get_public_schema_name()) 46 | .values_list("schema_name", flat=True) 47 | ) 48 | executor.run_migrations(tenants=tenants) 49 | -------------------------------------------------------------------------------- /tenant_schemas/management/commands/tenant_command.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.core.management.base import BaseCommand 3 | from django.db import connection 4 | from tenant_schemas.management.commands import InteractiveTenantOption 5 | 6 | 7 | class Command(InteractiveTenantOption, BaseCommand): 8 | help = "Wrapper around django commands for use with an individual tenant" 9 | 10 | def handle(self, command, schema_name, *args, **options): 11 | tenant = self.get_tenant_from_options_or_interactive( 12 | schema_name=schema_name, **options 13 | ) 14 | connection.set_tenant(tenant) 15 | call_command(command, *args, **options) 16 | -------------------------------------------------------------------------------- /tenant_schemas/middleware.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.conf import settings 3 | from django.core.exceptions import DisallowedHost 4 | from django.db import connection 5 | from django.http import Http404 6 | from tenant_schemas.utils import ( 7 | get_public_schema_name, 8 | get_tenant_model, 9 | remove_www, 10 | ) 11 | 12 | 13 | """ 14 | These middlewares should be placed at the very top of the middleware stack. 15 | Selects the proper database schema using request information. Can fail in 16 | various ways which is better than corrupting or revealing data. 17 | 18 | Extend BaseTenantMiddleware for a custom tenant selection strategy, 19 | such as inspecting the header, or extracting it from some OAuth token. 20 | """ 21 | 22 | 23 | class BaseTenantMiddleware(django.utils.deprecation.MiddlewareMixin): 24 | TENANT_NOT_FOUND_EXCEPTION = Http404 25 | 26 | """ 27 | Subclass and override this to achieve desired behaviour. Given a 28 | request, return the tenant to use. Tenant should be an instance 29 | of TENANT_MODEL. We have three parameters for backwards compatibility 30 | (the request would be enough). 31 | """ 32 | 33 | def get_tenant(self, model, hostname, request): 34 | raise NotImplementedError 35 | 36 | def hostname_from_request(self, request): 37 | """ Extracts hostname from request. Used for custom requests filtering. 38 | By default removes the request's port and common prefixes. 39 | """ 40 | return remove_www(request.get_host().split(":")[0]).lower() 41 | 42 | def process_request(self, request): 43 | # Connection needs first to be at the public schema, as this is where 44 | # the tenant metadata is stored. 45 | connection.set_schema_to_public() 46 | 47 | hostname = self.hostname_from_request(request) 48 | TenantModel = get_tenant_model() 49 | 50 | try: 51 | # get_tenant must be implemented by extending this class. 52 | tenant = self.get_tenant(TenantModel, hostname, request) 53 | assert isinstance(tenant, TenantModel) 54 | except TenantModel.DoesNotExist: 55 | raise self.TENANT_NOT_FOUND_EXCEPTION( 56 | "No tenant for {!r}".format(request.get_host()) 57 | ) 58 | except AssertionError: 59 | raise self.TENANT_NOT_FOUND_EXCEPTION( 60 | "Invalid tenant {!r}".format(request.tenant) 61 | ) 62 | 63 | request.tenant = tenant 64 | connection.set_tenant(request.tenant) 65 | 66 | # Do we have a public-specific urlconf? 67 | if ( 68 | hasattr(settings, "PUBLIC_SCHEMA_URLCONF") 69 | and request.tenant.schema_name == get_public_schema_name() 70 | ): 71 | request.urlconf = settings.PUBLIC_SCHEMA_URLCONF 72 | 73 | 74 | class TenantMiddleware(BaseTenantMiddleware): 75 | """ 76 | Selects the proper database schema using the request host. E.g. . 77 | """ 78 | 79 | def get_tenant(self, model, hostname, request): 80 | return model.objects.get(domain_url=hostname) 81 | 82 | 83 | class SuspiciousTenantMiddleware(TenantMiddleware): 84 | """ 85 | Extend the TenantMiddleware in scenario where you need to configure 86 | ``ALLOWED_HOSTS`` to allow ANY domain_url to be used because your tenants 87 | can bring any custom domain with them, as opposed to all tenants being a 88 | subdomain of a common base. 89 | 90 | See https://github.com/bernardopires/django-tenant-schemas/pull/269 for 91 | discussion on this middleware. 92 | """ 93 | 94 | TENANT_NOT_FOUND_EXCEPTION = DisallowedHost 95 | 96 | 97 | class DefaultTenantMiddleware(SuspiciousTenantMiddleware): 98 | """ 99 | Extend the SuspiciousTenantMiddleware in scenario where you want to 100 | configure a tenant to be served if the hostname does not match any of the 101 | existing tenants. 102 | 103 | Subclass and override DEFAULT_SCHEMA_NAME to use a schema other than the 104 | public schema. 105 | 106 | class MyTenantMiddleware(DefaultTenantMiddleware): 107 | DEFAULT_SCHEMA_NAME = 'default' 108 | """ 109 | 110 | DEFAULT_SCHEMA_NAME = None 111 | 112 | def get_tenant(self, model, hostname, request): 113 | try: 114 | return super(DefaultTenantMiddleware, self).get_tenant( 115 | model, hostname, request 116 | ) 117 | except model.DoesNotExist: 118 | schema_name = self.DEFAULT_SCHEMA_NAME 119 | if not schema_name: 120 | schema_name = get_public_schema_name() 121 | 122 | return model.objects.get(schema_name=schema_name) 123 | -------------------------------------------------------------------------------- /tenant_schemas/migration_executors/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from tenant_schemas.migration_executors.base import MigrationExecutor 4 | from tenant_schemas.migration_executors.parallel import ParallelExecutor 5 | from tenant_schemas.migration_executors.standard import StandardExecutor 6 | 7 | 8 | def get_executor(codename=None): 9 | codename = codename or os.environ.get('EXECUTOR', StandardExecutor.codename) 10 | 11 | for klass in MigrationExecutor.__subclasses__(): 12 | if klass.codename == codename: 13 | return klass 14 | 15 | raise NotImplementedError('No executor with codename %s' % codename) 16 | -------------------------------------------------------------------------------- /tenant_schemas/migration_executors/base.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management.commands.migrate import Command as MigrateCommand 4 | from django.db import transaction 5 | 6 | from tenant_schemas.utils import get_public_schema_name 7 | 8 | 9 | def run_migrations(args, options, executor_codename, schema_name, allow_atomic=True): 10 | from django.core.management import color 11 | from django.core.management.base import OutputWrapper 12 | from django.db import connection 13 | 14 | style = color.color_style() 15 | 16 | def style_func(msg): 17 | return '[%s:%s] %s' % ( 18 | style.NOTICE(executor_codename), 19 | style.NOTICE(schema_name), 20 | msg 21 | ) 22 | 23 | stdout = OutputWrapper(sys.stdout) 24 | stdout.style_func = style_func 25 | stderr = OutputWrapper(sys.stderr) 26 | stderr.style_func = style_func 27 | if int(options.get('verbosity', 1)) >= 1: 28 | stdout.write(style.NOTICE("=== Running migrate for schema %s" % schema_name)) 29 | 30 | connection.set_schema(schema_name) 31 | MigrateCommand(stdout=stdout, stderr=stderr).execute(*args, **options) 32 | 33 | try: 34 | transaction.commit() 35 | connection.close() 36 | connection.connection = None 37 | except transaction.TransactionManagementError: 38 | if not allow_atomic: 39 | raise 40 | 41 | # We are in atomic transaction, don't close connections 42 | pass 43 | 44 | connection.set_schema_to_public() 45 | 46 | 47 | class MigrationExecutor(object): 48 | codename = None 49 | 50 | def __init__(self, args, options): 51 | self.args = args 52 | self.options = options 53 | 54 | def run_migrations(self, tenants): 55 | public_schema_name = get_public_schema_name() 56 | 57 | if public_schema_name in tenants: 58 | run_migrations(self.args, self.options, self.codename, public_schema_name) 59 | tenants.pop(tenants.index(public_schema_name)) 60 | 61 | self.run_tenant_migrations(tenants) 62 | 63 | def run_tenant_migrations(self, tenant): 64 | raise NotImplementedError 65 | -------------------------------------------------------------------------------- /tenant_schemas/migration_executors/parallel.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import multiprocessing 3 | 4 | from django.conf import settings 5 | 6 | from tenant_schemas.migration_executors.base import MigrationExecutor, run_migrations 7 | 8 | 9 | class ParallelExecutor(MigrationExecutor): 10 | codename = 'parallel' 11 | 12 | def run_tenant_migrations(self, tenants): 13 | if tenants: 14 | processes = getattr(settings, 'TENANT_PARALLEL_MIGRATION_MAX_PROCESSES', 2) 15 | chunks = getattr(settings, 'TENANT_PARALLEL_MIGRATION_CHUNKS', 2) 16 | 17 | from django.db import connection 18 | 19 | connection.close() 20 | connection.connection = None 21 | 22 | run_migrations_p = functools.partial( 23 | run_migrations, 24 | self.args, 25 | self.options, 26 | self.codename, 27 | allow_atomic=False 28 | ) 29 | p = multiprocessing.Pool(processes=processes) 30 | p.map(run_migrations_p, tenants, chunks) 31 | -------------------------------------------------------------------------------- /tenant_schemas/migration_executors/standard.py: -------------------------------------------------------------------------------- 1 | from tenant_schemas.migration_executors.base import MigrationExecutor, run_migrations 2 | 3 | 4 | class StandardExecutor(MigrationExecutor): 5 | codename = 'standard' 6 | 7 | def run_tenant_migrations(self, tenants): 8 | for schema_name in tenants: 9 | run_migrations(self.args, self.options, self.codename, schema_name) 10 | -------------------------------------------------------------------------------- /tenant_schemas/models.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.db import connection, models 3 | 4 | from tenant_schemas.postgresql_backend.base import _check_schema_name 5 | from tenant_schemas.signals import post_schema_sync 6 | from tenant_schemas.utils import get_public_schema_name, schema_exists 7 | 8 | 9 | class TenantQueryset(models.QuerySet): 10 | """ 11 | QuerySet for instances that inherit from the TenantMixin. 12 | """ 13 | def delete(self): 14 | """ 15 | Make sure we call the delete method of each object in the queryset so 16 | that safety checks and schema deletion (if requested) are executed 17 | even when using bulk delete. 18 | """ 19 | counter, counter_dict = 0, {} 20 | for obj in self: 21 | result = obj.delete() 22 | if result is not None: 23 | current_counter, current_counter_dict = result 24 | counter += current_counter 25 | counter_dict.update(current_counter_dict) 26 | if counter: 27 | return counter, counter_dict 28 | 29 | 30 | class TenantMixin(models.Model): 31 | """ 32 | All tenant models must inherit this class. 33 | """ 34 | 35 | auto_drop_schema = False 36 | """ 37 | USE THIS WITH CAUTION! 38 | Set this flag to true on a parent class if you want the schema to be 39 | automatically deleted if the tenant row gets deleted. 40 | """ 41 | 42 | auto_create_schema = True 43 | """ 44 | Set this flag to false on a parent class if you don't want the schema 45 | to be automatically created upon save. 46 | """ 47 | 48 | domain_url = models.CharField(max_length=128, unique=True) 49 | schema_name = models.CharField(max_length=63, unique=True, 50 | validators=[_check_schema_name]) 51 | objects = TenantQueryset.as_manager() 52 | 53 | class Meta: 54 | abstract = True 55 | 56 | def save(self, verbosity=1, *args, **kwargs): 57 | is_new = self.pk is None 58 | 59 | if is_new and connection.schema_name != get_public_schema_name(): 60 | raise Exception("Can't create tenant outside the public schema. " 61 | "Current schema is %s." % connection.schema_name) 62 | elif not is_new and connection.schema_name not in (self.schema_name, get_public_schema_name()): 63 | raise Exception("Can't update tenant outside it's own schema or " 64 | "the public schema. Current schema is %s." 65 | % connection.schema_name) 66 | 67 | super(TenantMixin, self).save(*args, **kwargs) 68 | 69 | if is_new and self.auto_create_schema: 70 | try: 71 | self.create_schema(check_if_exists=True, verbosity=verbosity) 72 | except: 73 | # We failed creating the tenant, delete what we created and 74 | # re-raise the exception 75 | self.delete(force_drop=True) 76 | raise 77 | else: 78 | post_schema_sync.send(sender=TenantMixin, tenant=self) 79 | 80 | def delete(self, force_drop=False, *args, **kwargs): 81 | """ 82 | Deletes this row. Drops the tenant's schema if the attribute 83 | auto_drop_schema set to True. 84 | """ 85 | if connection.schema_name not in (self.schema_name, get_public_schema_name()): 86 | raise Exception("Can't delete tenant outside it's own schema or " 87 | "the public schema. Current schema is %s." 88 | % connection.schema_name) 89 | 90 | if schema_exists(self.schema_name) and (self.auto_drop_schema or force_drop): 91 | cursor = connection.cursor() 92 | cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE' % self.schema_name) 93 | 94 | return super(TenantMixin, self).delete(*args, **kwargs) 95 | 96 | def create_schema(self, check_if_exists=False, sync_schema=True, 97 | verbosity=1): 98 | """ 99 | Creates the schema 'schema_name' for this tenant. Optionally checks if 100 | the schema already exists before creating it. Returns true if the 101 | schema was created, false otherwise. 102 | """ 103 | 104 | # safety check 105 | _check_schema_name(self.schema_name) 106 | cursor = connection.cursor() 107 | 108 | if check_if_exists and schema_exists(self.schema_name): 109 | return False 110 | 111 | # create the schema 112 | cursor.execute('CREATE SCHEMA %s' % self.schema_name) 113 | 114 | if sync_schema: 115 | call_command('migrate_schemas', 116 | schema_name=self.schema_name, 117 | interactive=False, 118 | verbosity=verbosity) 119 | 120 | connection.set_schema_to_public() 121 | -------------------------------------------------------------------------------- /tenant_schemas/postgresql_backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/tenant_schemas/postgresql_backend/__init__.py -------------------------------------------------------------------------------- /tenant_schemas/postgresql_backend/base.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | import psycopg2 4 | 5 | from django.conf import settings 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.core.exceptions import ImproperlyConfigured, ValidationError 8 | import django.db.utils 9 | 10 | from tenant_schemas.utils import get_public_schema_name, get_limit_set_calls 11 | from tenant_schemas.postgresql_backend.introspection import DatabaseSchemaIntrospection 12 | 13 | 14 | ORIGINAL_BACKEND = getattr(settings, 'ORIGINAL_BACKEND', 'django.db.backends.postgresql_psycopg2') 15 | # Django 1.9+ takes care to rename the default backend to 'django.db.backends.postgresql' 16 | original_backend = django.db.utils.load_backend(ORIGINAL_BACKEND) 17 | 18 | EXTRA_SEARCH_PATHS = getattr(settings, 'PG_EXTRA_SEARCH_PATHS', []) 19 | 20 | # from the postgresql doc 21 | SQL_IDENTIFIER_RE = re.compile(r'^[_a-zA-Z][_a-zA-Z0-9]{,62}$') 22 | SQL_SCHEMA_NAME_RESERVED_RE = re.compile(r'^pg_', re.IGNORECASE) 23 | 24 | 25 | def _is_valid_identifier(identifier): 26 | return bool(SQL_IDENTIFIER_RE.match(identifier)) 27 | 28 | 29 | def _check_identifier(identifier): 30 | if not _is_valid_identifier(identifier): 31 | raise ValidationError("Invalid string used for the identifier.") 32 | 33 | 34 | def _is_valid_schema_name(name): 35 | return _is_valid_identifier(name) and not SQL_SCHEMA_NAME_RESERVED_RE.match(name) 36 | 37 | 38 | def _check_schema_name(name): 39 | if not _is_valid_schema_name(name): 40 | raise ValidationError("Invalid string used for the schema name.") 41 | 42 | 43 | class DatabaseWrapper(original_backend.DatabaseWrapper): 44 | """ 45 | Adds the capability to manipulate the search_path using set_tenant and set_schema_name 46 | """ 47 | include_public_schema = True 48 | 49 | def __init__(self, *args, **kwargs): 50 | super(DatabaseWrapper, self).__init__(*args, **kwargs) 51 | 52 | # Use a patched version of the DatabaseIntrospection that only returns the table list for the 53 | # currently selected schema. 54 | self.introspection = DatabaseSchemaIntrospection(self) 55 | self.set_schema_to_public() 56 | 57 | def close(self): 58 | self.search_path_set = False 59 | super(DatabaseWrapper, self).close() 60 | 61 | def rollback(self): 62 | super(DatabaseWrapper, self).rollback() 63 | # Django's rollback clears the search path so we have to set it again the next time. 64 | self.search_path_set = False 65 | 66 | def set_tenant(self, tenant, include_public=True): 67 | """ 68 | Main API method to current database schema, 69 | but it does not actually modify the db connection. 70 | """ 71 | self.set_schema(tenant.schema_name, include_public) 72 | self.tenant = tenant 73 | 74 | def set_schema(self, schema_name, include_public=True): 75 | """ 76 | Main API method to current database schema, 77 | but it does not actually modify the db connection. 78 | """ 79 | self.tenant = FakeTenant(schema_name=schema_name) 80 | self.schema_name = schema_name 81 | self.include_public_schema = include_public 82 | self.set_settings_schema(schema_name) 83 | self.search_path_set = False 84 | # Content type can no longer be cached as public and tenant schemas 85 | # have different models. If someone wants to change this, the cache 86 | # needs to be separated between public and shared schemas. If this 87 | # cache isn't cleared, this can cause permission problems. For example, 88 | # on public, a particular model has id 14, but on the tenants it has 89 | # the id 15. if 14 is cached instead of 15, the permissions for the 90 | # wrong model will be fetched. 91 | ContentType.objects.clear_cache() 92 | 93 | def set_schema_to_public(self): 94 | """ 95 | Instructs to stay in the common 'public' schema. 96 | """ 97 | self.set_schema(get_public_schema_name()) 98 | 99 | def set_settings_schema(self, schema_name): 100 | self.settings_dict['SCHEMA'] = schema_name 101 | 102 | def get_schema(self): 103 | warnings.warn("connection.get_schema() is deprecated, use connection.schema_name instead.", 104 | category=DeprecationWarning) 105 | return self.schema_name 106 | 107 | def get_tenant(self): 108 | warnings.warn("connection.get_tenant() is deprecated, use connection.tenant instead.", 109 | category=DeprecationWarning) 110 | return self.tenant 111 | 112 | def _cursor(self, name=None): 113 | """ 114 | Here it happens. We hope every Django db operation using PostgreSQL 115 | must go through this to get the cursor handle. We change the path. 116 | """ 117 | if name: 118 | # Only supported and required by Django 1.11 (server-side cursor) 119 | cursor = super(DatabaseWrapper, self)._cursor(name=name) 120 | else: 121 | cursor = super(DatabaseWrapper, self)._cursor() 122 | 123 | # optionally limit the number of executions - under load, the execution 124 | # of `set search_path` can be quite time consuming 125 | if (not get_limit_set_calls()) or not self.search_path_set: 126 | # Actual search_path modification for the cursor. Database will 127 | # search schemata from left to right when looking for the object 128 | # (table, index, sequence, etc.). 129 | if not self.schema_name: 130 | raise ImproperlyConfigured("Database schema not set. Did you forget " 131 | "to call set_schema() or set_tenant()?") 132 | _check_schema_name(self.schema_name) 133 | public_schema_name = get_public_schema_name() 134 | search_paths = [] 135 | 136 | if self.schema_name == public_schema_name: 137 | search_paths = [public_schema_name] 138 | elif self.include_public_schema: 139 | search_paths = [self.schema_name, public_schema_name] 140 | else: 141 | search_paths = [self.schema_name] 142 | 143 | search_paths.extend(EXTRA_SEARCH_PATHS) 144 | 145 | if name: 146 | # Named cursor can only be used once 147 | cursor_for_search_path = self.connection.cursor() 148 | else: 149 | # Reuse 150 | cursor_for_search_path = cursor 151 | 152 | # In the event that an error already happened in this transaction and we are going 153 | # to rollback we should just ignore database error when setting the search_path 154 | # if the next instruction is not a rollback it will just fail also, so 155 | # we do not have to worry that it's not the good one 156 | try: 157 | cursor_for_search_path.execute('SET search_path = {0}'.format(','.join(search_paths))) 158 | except (django.db.utils.DatabaseError, psycopg2.InternalError): 159 | self.search_path_set = False 160 | else: 161 | self.search_path_set = True 162 | 163 | if name: 164 | cursor_for_search_path.close() 165 | 166 | return cursor 167 | 168 | 169 | class FakeTenant: 170 | """ 171 | We can't import any db model in a backend (apparently?), so this class is used 172 | for wrapping schema names in a tenant-like structure. 173 | """ 174 | def __init__(self, schema_name): 175 | self.schema_name = schema_name 176 | -------------------------------------------------------------------------------- /tenant_schemas/postgresql_backend/introspection.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from collections import namedtuple 4 | 5 | from django.db.backends.base.introspection import ( 6 | BaseDatabaseIntrospection, FieldInfo, TableInfo, 7 | ) 8 | try: 9 | # Django >= 1.11 10 | from django.db.models.indexes import Index 11 | except ImportError: 12 | Index = None 13 | from django.utils.encoding import force_text 14 | 15 | fields = FieldInfo._fields 16 | if 'default' not in fields: 17 | fields += ('default',) 18 | 19 | FieldInfo = namedtuple('FieldInfo', fields) 20 | 21 | 22 | class DatabaseSchemaIntrospection(BaseDatabaseIntrospection): 23 | # Maps type codes to Django Field types. 24 | data_types_reverse = { 25 | 16: 'BooleanField', 26 | 17: 'BinaryField', 27 | 20: 'BigIntegerField', 28 | 21: 'SmallIntegerField', 29 | 23: 'IntegerField', 30 | 25: 'TextField', 31 | 700: 'FloatField', 32 | 701: 'FloatField', 33 | 869: 'GenericIPAddressField', 34 | 1042: 'CharField', # blank-padded 35 | 1043: 'CharField', 36 | 1082: 'DateField', 37 | 1083: 'TimeField', 38 | 1114: 'DateTimeField', 39 | 1184: 'DateTimeField', 40 | 1266: 'TimeField', 41 | 1700: 'DecimalField', 42 | 2950: 'UUIDField', 43 | } 44 | 45 | ignored_tables = [] 46 | 47 | _get_table_list_query = """ 48 | SELECT c.relname, c.relkind 49 | FROM pg_catalog.pg_class c 50 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace 51 | WHERE c.relkind IN ('r', 'v') 52 | AND n.nspname = %(schema)s; 53 | """ 54 | 55 | _get_table_description_query = """ 56 | SELECT column_name, is_nullable, column_default 57 | FROM information_schema.columns 58 | WHERE table_name = %(table)s 59 | AND table_schema = %(schema)s 60 | """ 61 | 62 | _get_relations_query = """ 63 | SELECT c2.relname, a1.attname, a2.attname 64 | FROM pg_constraint con 65 | LEFT JOIN pg_class c1 ON con.conrelid = c1.oid 66 | LEFT JOIN pg_class c2 ON con.confrelid = c2.oid 67 | LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] 68 | LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] 69 | LEFT JOIN pg_catalog.pg_namespace n1 ON n1.oid = con.connamespace 70 | WHERE c1.relname = %(table)s 71 | AND n1.nspname = %(schema)s 72 | AND con.contype = 'f' 73 | """ 74 | 75 | _get_key_columns_query = """ 76 | SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column 77 | FROM information_schema.constraint_column_usage ccu 78 | LEFT JOIN information_schema.key_column_usage kcu 79 | ON ccu.constraint_catalog = kcu.constraint_catalog 80 | AND ccu.constraint_schema = kcu.constraint_schema 81 | AND ccu.constraint_name = kcu.constraint_name 82 | LEFT JOIN information_schema.table_constraints tc 83 | ON ccu.constraint_catalog = tc.constraint_catalog 84 | AND ccu.constraint_schema = tc.constraint_schema 85 | AND ccu.constraint_name = tc.constraint_name 86 | WHERE kcu.table_name = %(table)s 87 | AND kcu.table_schame = %(schema)s 88 | AND tc.constraint_type = 'FOREIGN KEY' 89 | """ 90 | 91 | _get_indexes_query = """ 92 | SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary 93 | FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, 94 | pg_catalog.pg_index idx, pg_catalog.pg_attribute attr 95 | WHERE c.oid = idx.indrelid 96 | AND idx.indexrelid = c2.oid 97 | AND attr.attrelid = c.oid 98 | AND attr.attnum = idx.indkey[0] 99 | AND c.relname = %(table)s 100 | AND n.nspname = %(schema)s 101 | """ 102 | 103 | _get_constraints_query = """ 104 | SELECT 105 | c.conname, 106 | array( 107 | SELECT attname 108 | FROM ( 109 | SELECT unnest(c.conkey) AS colid, 110 | generate_series(1, array_length(c.conkey, 1)) AS arridx 111 | ) AS cols 112 | JOIN pg_attribute AS ca ON cols.colid = ca.attnum 113 | WHERE ca.attrelid = c.conrelid 114 | ORDER BY cols.arridx 115 | ), 116 | c.contype, 117 | (SELECT fkc.relname || '.' || fka.attname 118 | FROM pg_attribute AS fka 119 | JOIN pg_class AS fkc ON fka.attrelid = fkc.oid 120 | WHERE fka.attrelid = c.confrelid 121 | AND fka.attnum = c.confkey[1]), 122 | cl.reloptions 123 | FROM pg_constraint AS c 124 | JOIN pg_class AS cl ON c.conrelid = cl.oid 125 | JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid 126 | WHERE ns.nspname = %(schema)s AND cl.relname = %(table)s 127 | """ 128 | 129 | _get_check_constraints_query = """ 130 | SELECT kc.constraint_name, kc.column_name 131 | FROM information_schema.constraint_column_usage AS kc 132 | JOIN information_schema.table_constraints AS c ON 133 | kc.table_schema = c.table_schema AND 134 | kc.table_name = c.table_name AND 135 | kc.constraint_name = c.constraint_name 136 | WHERE 137 | c.constraint_type = 'CHECK' AND 138 | kc.table_schema = %(schema)s AND 139 | kc.table_name = %(table)s 140 | """ 141 | 142 | _get_index_constraints_query = """ 143 | SELECT 144 | indexname, array_agg(attname ORDER BY rnum), indisunique, indisprimary, 145 | array_agg(ordering ORDER BY rnum), amname, exprdef, s2.attoptions 146 | FROM ( 147 | SELECT 148 | row_number() OVER () as rnum, c2.relname as indexname, 149 | idx.*, attr.attname, am.amname, 150 | CASE 151 | WHEN idx.indexprs IS NOT NULL THEN 152 | pg_get_indexdef(idx.indexrelid) 153 | END AS exprdef, 154 | CASE am.amname 155 | WHEN 'btree' THEN 156 | CASE (option & 1) 157 | WHEN 1 THEN 'DESC' ELSE 'ASC' 158 | END 159 | END as ordering, 160 | c2.reloptions as attoptions 161 | FROM ( 162 | SELECT 163 | *, unnest(i.indkey) as key, unnest(i.indoption) as option 164 | FROM pg_index i 165 | ) idx 166 | LEFT JOIN pg_class c ON idx.indrelid = c.oid 167 | LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid 168 | LEFT JOIN pg_am am ON c2.relam = am.oid 169 | LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key 170 | LEFT JOIN pg_namespace n ON c.relnamespace = n.oid 171 | WHERE c.relname = %(table)s 172 | AND n.nspname = %(schema)s 173 | ) s2 174 | GROUP BY indexname, indisunique, indisprimary, amname, exprdef, attoptions; 175 | """ 176 | 177 | def get_field_type(self, data_type, description): 178 | field_type = super(DatabaseSchemaIntrospection, self).get_field_type(data_type, description) 179 | if description.default and 'nextval' in description.default: 180 | if field_type == 'IntegerField': 181 | return 'AutoField' 182 | elif field_type == 'BigIntegerField': 183 | return 'BigAutoField' 184 | return field_type 185 | 186 | def get_table_list(self, cursor): 187 | """ 188 | Returns a list of table and view names in the current schema. 189 | """ 190 | cursor.execute(self._get_table_list_query, { 191 | 'schema': self.connection.schema_name 192 | }) 193 | 194 | return [ 195 | TableInfo(row[0], {'r': 't', 'v': 'v'}.get(row[1])) 196 | for row in cursor.fetchall() 197 | if row[0] not in self.ignored_tables 198 | ] 199 | 200 | def get_table_description(self, cursor, table_name): 201 | """ 202 | Returns a description of the table, with the DB-API cursor.description interface. 203 | """ 204 | 205 | # As cursor.description does not return reliably the nullable property, 206 | # we have to query the information_schema (#7783) 207 | cursor.execute(self._get_table_description_query, { 208 | 'schema': self.connection.schema_name, 209 | 'table': table_name 210 | }) 211 | field_map = {line[0]: line[1:] for line in cursor.fetchall()} 212 | cursor.execute('SELECT * FROM %s LIMIT 1' % self.connection.ops.quote_name(table_name)) 213 | 214 | return [ 215 | FieldInfo(*( 216 | (force_text(line[0]),) + 217 | line[1:6] + 218 | (field_map[force_text(line[0])][0] == 'YES', field_map[force_text(line[0])][1]) 219 | )) for line in cursor.description 220 | ] 221 | 222 | def get_relations(self, cursor, table_name): 223 | """ 224 | Returns a dictionary of {field_name: (field_name_other_table, other_table)} 225 | representing all relationships to the given table. 226 | """ 227 | cursor.execute(self._get_relations_query, { 228 | 'schema': self.connection.schema_name, 229 | 'table': table_name 230 | }) 231 | relations = {} 232 | for row in cursor.fetchall(): 233 | relations[row[1]] = (row[2], row[0]) 234 | 235 | return relations 236 | 237 | def get_key_columns(self, cursor, table_name): 238 | cursor.execute(self._get_key_columns_query, { 239 | 'schema': self.connection.schema_name, 240 | 'table': table_name 241 | }) 242 | return list(cursor.fetchall()) 243 | 244 | def get_indexes(self, cursor, table_name): 245 | # This query retrieves each index on the given table, including the 246 | # first associated field name 247 | cursor.execute(self._get_indexes_query, { 248 | 'schema': self.connection.schema_name, 249 | 'table': table_name, 250 | }) 251 | indexes = {} 252 | for row in cursor.fetchall(): 253 | # row[1] (idx.indkey) is stored in the DB as an array. It comes out as 254 | # a string of space-separated integers. This designates the field 255 | # indexes (1-based) of the fields that have indexes on the table. 256 | # Here, we skip any indexes across multiple fields. 257 | if ' ' in row[1]: 258 | continue 259 | if row[0] not in indexes: 260 | indexes[row[0]] = {'primary_key': False, 'unique': False} 261 | # It's possible to have the unique and PK constraints in separate indexes. 262 | if row[3]: 263 | indexes[row[0]]['primary_key'] = True 264 | if row[2]: 265 | indexes[row[0]]['unique'] = True 266 | return indexes 267 | 268 | def get_constraints(self, cursor, table_name): 269 | """ 270 | Retrieves any constraints or keys (unique, pk, fk, check, index) across 271 | one or more columns. Also retrieve the definition of expression-based 272 | indexes. 273 | """ 274 | constraints = {} 275 | # Loop over the key table, collecting things as constraints. The column 276 | # array must return column names in the same order in which they were 277 | # created 278 | # The subquery containing generate_series can be replaced with 279 | # "WITH ORDINALITY" when support for PostgreSQL 9.3 is dropped. 280 | cursor.execute(self._get_constraints_query, { 281 | 'schema': self.connection.schema_name, 282 | 'table': table_name, 283 | }) 284 | 285 | for constraint, columns, kind, used_cols, options in cursor.fetchall(): 286 | constraints[constraint] = { 287 | "columns": columns, 288 | "primary_key": kind == "p", 289 | "unique": kind in ["p", "u"], 290 | "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, 291 | "check": kind == "c", 292 | "index": False, 293 | "definition": None, 294 | "options": options, 295 | } 296 | 297 | # Now get indexes 298 | cursor.execute(self._get_index_constraints_query, { 299 | 'schema': self.connection.schema_name, 300 | 'table': table_name, 301 | }) 302 | 303 | for index, columns, unique, primary, orders, type_, definition, options in cursor.fetchall(): 304 | if index not in constraints: 305 | constraints[index] = { 306 | "columns": columns if columns != [None] else [], 307 | "orders": orders if orders != [None] else [], 308 | "primary_key": primary, 309 | "unique": unique, 310 | "foreign_key": None, 311 | "check": False, 312 | "index": True, 313 | "type": Index.suffix if type_ == 'btree' and Index else type_, 314 | "definition": definition, 315 | "options": options, 316 | } 317 | return constraints 318 | -------------------------------------------------------------------------------- /tenant_schemas/routers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models.base import ModelBase 3 | from django.db.utils import load_backend 4 | 5 | 6 | class TenantSyncRouter(object): 7 | """ 8 | A router to control which applications will be synced, 9 | depending if we are syncing the shared apps or the tenant apps. 10 | """ 11 | 12 | def allow_migrate(self, db, app_label, model_name=None, **hints): 13 | # the imports below need to be done here else django <1.5 goes crazy 14 | # https://code.djangoproject.com/ticket/20704 15 | from django.db import connection 16 | from tenant_schemas.utils import get_public_schema_name, app_labels 17 | from tenant_schemas.postgresql_backend.base import DatabaseWrapper as TenantDbWrapper 18 | 19 | db_engine = settings.DATABASES[db]['ENGINE'] 20 | if not (db_engine == 'tenant_schemas.postgresql_backend' or 21 | issubclass(getattr(load_backend(db_engine), 'DatabaseWrapper'), TenantDbWrapper)): 22 | return None 23 | 24 | if isinstance(app_label, ModelBase): 25 | # In django <1.7 the `app_label` parameter is actually `model` 26 | app_label = app_label._meta.app_label 27 | 28 | if connection.schema_name == get_public_schema_name(): 29 | if app_label not in app_labels(settings.SHARED_APPS): 30 | return False 31 | else: 32 | if app_label not in app_labels(settings.TENANT_APPS): 33 | return False 34 | 35 | return None 36 | 37 | def allow_syncdb(self, db, model): 38 | # allow_syncdb was changed to allow_migrate in django 1.7 39 | return self.allow_migrate(db, model) 40 | -------------------------------------------------------------------------------- /tenant_schemas/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | post_schema_sync = Signal() 4 | post_schema_sync.__doc__ = """ 5 | Sent after a tenant has been saved, its schema created and synced 6 | """ 7 | -------------------------------------------------------------------------------- /tenant_schemas/storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.exceptions import SuspiciousOperation 4 | from django.utils._os import safe_join 5 | 6 | from django.db import connection 7 | 8 | from django.core.files.storage import FileSystemStorage 9 | from django.contrib.staticfiles.storage import StaticFilesStorage 10 | 11 | __all__ = ( 12 | 'TenantStorageMixin', 13 | 'TenantFileSystemStorage', 14 | 'TenantStaticFilesStorage', 15 | ) 16 | 17 | 18 | class TenantStorageMixin(object): 19 | """ 20 | Mixin that can be combined with other Storage backends to colocate media 21 | for all tenants in distinct subdirectories. 22 | 23 | Using rewriting rules at the reverse proxy we can determine which content 24 | gets served up, while any code interactions will account for the multiple 25 | tenancy of the project. 26 | """ 27 | def path(self, name): 28 | """ 29 | Look for files in subdirectory of MEDIA_ROOT using the tenant's 30 | domain_url value as the specifier. 31 | """ 32 | if name is None: 33 | name = '' 34 | try: 35 | location = safe_join(self.location, connection.tenant.domain_url) 36 | except AttributeError: 37 | location = self.location 38 | try: 39 | path = safe_join(location, name) 40 | except ValueError: 41 | raise SuspiciousOperation( 42 | "Attempted access to '%s' denied." % name) 43 | return os.path.normpath(path) 44 | 45 | 46 | class TenantFileSystemStorage(TenantStorageMixin, FileSystemStorage): 47 | """ 48 | Implementation that extends core Django's FileSystemStorage. 49 | """ 50 | 51 | 52 | class TenantStaticFilesStorage(TenantStorageMixin, StaticFilesStorage): 53 | """ 54 | Implementation that extends core Django's StaticFilesStorage. 55 | """ 56 | -------------------------------------------------------------------------------- /tenant_schemas/template_loaders.py: -------------------------------------------------------------------------------- 1 | """ 2 | Adaptations of the cached and filesystem template loader working in a 3 | multi-tenant setting 4 | """ 5 | 6 | from django.conf import settings 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.db import connection 9 | from django.template.loaders import cached, filesystem 10 | from ordered_set import OrderedSet 11 | from tenant_schemas.postgresql_backend.base import FakeTenant 12 | 13 | 14 | class CachedLoader(cached.Loader): 15 | def cache_key(self, *args, **kwargs): 16 | key = super(CachedLoader, self).cache_key(*args, **kwargs) 17 | 18 | if not connection.tenant or isinstance(connection.tenant, FakeTenant): 19 | return key 20 | 21 | return "-".join([connection.tenant.schema_name, key]) 22 | 23 | 24 | class FilesystemLoader(filesystem.Loader): 25 | def get_dirs(self): 26 | dirs = OrderedSet(super(FilesystemLoader, self).get_dirs()) 27 | 28 | if connection.tenant and not isinstance(connection.tenant, FakeTenant): 29 | try: 30 | template_dirs = settings.MULTITENANT_TEMPLATE_DIRS 31 | except AttributeError: 32 | raise ImproperlyConfigured( 33 | "To use %s.%s you must define the MULTITENANT_TEMPLATE_DIRS" 34 | % (__name__, FilesystemLoader.__name__) 35 | ) 36 | 37 | for template_dir in reversed(template_dirs): 38 | dirs.update( 39 | [ 40 | template_dir % (connection.tenant.domain_url,) 41 | if "%s" in template_dir 42 | else template_dir, 43 | ] 44 | ) 45 | 46 | return [each for each in reversed(dirs)] 47 | -------------------------------------------------------------------------------- /tenant_schemas/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/tenant_schemas/templatetags/__init__.py -------------------------------------------------------------------------------- /tenant_schemas/templatetags/tenant.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from django.template.defaulttags import url as default_url, URLNode 3 | from tenant_schemas.utils import clean_tenant_url 4 | 5 | register = Library() 6 | 7 | 8 | class SchemaURLNode(URLNode): 9 | def __init__(self, url_node): 10 | super(SchemaURLNode, self).__init__(url_node.view_name, url_node.args, url_node.kwargs, url_node.asvar) 11 | 12 | def render(self, context): 13 | url = super(SchemaURLNode, self).render(context) 14 | return clean_tenant_url(url) 15 | 16 | 17 | @register.tag 18 | def url(parser, token): 19 | return SchemaURLNode(default_url(parser, token)) 20 | -------------------------------------------------------------------------------- /tenant_schemas/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-pg-tenants/e699343bfa50aa70f7422c50f0f146bd801a90ec/tenant_schemas/test/__init__.py -------------------------------------------------------------------------------- /tenant_schemas/test/cases.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management import call_command 3 | from django.db import connection 4 | from django.test import TestCase 5 | from tenant_schemas.utils import get_public_schema_name, get_tenant_model 6 | 7 | ALLOWED_TEST_DOMAIN = '.test.com' 8 | 9 | 10 | class TenantTestCase(TestCase): 11 | @classmethod 12 | def add_allowed_test_domain(cls): 13 | # ALLOWED_HOSTS is a special setting of Django setup_test_environment so we can't modify it with helpers 14 | if ALLOWED_TEST_DOMAIN not in settings.ALLOWED_HOSTS: 15 | settings.ALLOWED_HOSTS += [ALLOWED_TEST_DOMAIN] 16 | 17 | @classmethod 18 | def remove_allowed_test_domain(cls): 19 | if ALLOWED_TEST_DOMAIN in settings.ALLOWED_HOSTS: 20 | settings.ALLOWED_HOSTS.remove(ALLOWED_TEST_DOMAIN) 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | cls.sync_shared() 25 | cls.add_allowed_test_domain() 26 | tenant_domain = 'tenant.test.com' 27 | cls.tenant = get_tenant_model()(domain_url=tenant_domain, schema_name='test') 28 | cls.tenant.save(verbosity=0) # todo: is there any way to get the verbosity from the test command here? 29 | 30 | connection.set_tenant(cls.tenant) 31 | 32 | @classmethod 33 | def tearDownClass(cls): 34 | connection.set_schema_to_public() 35 | cls.tenant.delete() 36 | 37 | cls.remove_allowed_test_domain() 38 | cursor = connection.cursor() 39 | cursor.execute('DROP SCHEMA IF EXISTS test CASCADE') 40 | 41 | @classmethod 42 | def sync_shared(cls): 43 | call_command('migrate_schemas', 44 | schema_name=get_public_schema_name(), 45 | interactive=False, 46 | verbosity=0) 47 | 48 | 49 | class FastTenantTestCase(TenantTestCase): 50 | @classmethod 51 | def setUpClass(cls): 52 | cls.sync_shared() 53 | cls.add_allowed_test_domain() 54 | tenant_domain = 'tenant.test.com' 55 | 56 | TenantModel = get_tenant_model() 57 | try: 58 | cls.tenant = TenantModel.objects.get(domain_url=tenant_domain, schema_name='test') 59 | except: 60 | cls.tenant = TenantModel(domain_url=tenant_domain, schema_name='test') 61 | cls.tenant.save(verbosity=0) 62 | 63 | connection.set_tenant(cls.tenant) 64 | 65 | @classmethod 66 | def tearDownClass(cls): 67 | connection.set_schema_to_public() 68 | cls.remove_allowed_test_domain() 69 | -------------------------------------------------------------------------------- /tenant_schemas/test/client.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory, Client 2 | from tenant_schemas.middleware import TenantMiddleware 3 | 4 | 5 | class TenantRequestFactory(RequestFactory): 6 | tm = TenantMiddleware() 7 | 8 | def __init__(self, tenant, **defaults): 9 | super(TenantRequestFactory, self).__init__(**defaults) 10 | self.tenant = tenant 11 | 12 | def get(self, path, data={}, **extra): 13 | if 'HTTP_HOST' not in extra: 14 | extra['HTTP_HOST'] = self.tenant.domain_url 15 | 16 | return super(TenantRequestFactory, self).get(path, data, **extra) 17 | 18 | def post(self, path, data={}, **extra): 19 | if 'HTTP_HOST' not in extra: 20 | extra['HTTP_HOST'] = self.tenant.domain_url 21 | 22 | return super(TenantRequestFactory, self).post(path, data, **extra) 23 | 24 | def patch(self, path, data={}, **extra): 25 | if 'HTTP_HOST' not in extra: 26 | extra['HTTP_HOST'] = self.tenant.domain_url 27 | 28 | return super(TenantRequestFactory, self).patch(path, data, **extra) 29 | 30 | def put(self, path, data={}, **extra): 31 | if 'HTTP_HOST' not in extra: 32 | extra['HTTP_HOST'] = self.tenant.domain_url 33 | 34 | return super(TenantRequestFactory, self).put(path, data, **extra) 35 | 36 | def delete(self, path, data='', content_type='application/octet-stream', 37 | **extra): 38 | if 'HTTP_HOST' not in extra: 39 | extra['HTTP_HOST'] = self.tenant.domain_url 40 | 41 | return super(TenantRequestFactory, self).delete(path, data, **extra) 42 | 43 | 44 | class TenantClient(Client): 45 | tm = TenantMiddleware() 46 | 47 | def __init__(self, tenant, enforce_csrf_checks=False, **defaults): 48 | super(TenantClient, self).__init__(enforce_csrf_checks, **defaults) 49 | self.tenant = tenant 50 | 51 | def get(self, path, data={}, **extra): 52 | if 'HTTP_HOST' not in extra: 53 | extra['HTTP_HOST'] = self.tenant.domain_url 54 | 55 | return super(TenantClient, self).get(path, data, **extra) 56 | 57 | def post(self, path, data={}, **extra): 58 | if 'HTTP_HOST' not in extra: 59 | extra['HTTP_HOST'] = self.tenant.domain_url 60 | 61 | return super(TenantClient, self).post(path, data, **extra) 62 | 63 | def patch(self, path, data={}, **extra): 64 | if 'HTTP_HOST' not in extra: 65 | extra['HTTP_HOST'] = self.tenant.domain_url 66 | 67 | return super(TenantClient, self).patch(path, data, **extra) 68 | 69 | def put(self, path, data={}, **extra): 70 | if 'HTTP_HOST' not in extra: 71 | extra['HTTP_HOST'] = self.tenant.domain_url 72 | 73 | return super(TenantClient, self).put(path, data, **extra) 74 | 75 | def delete(self, path, data='', content_type='application/octet-stream', 76 | **extra): 77 | if 'HTTP_HOST' not in extra: 78 | extra['HTTP_HOST'] = self.tenant.domain_url 79 | 80 | return super(TenantClient, self).delete(path, data, **extra) 81 | -------------------------------------------------------------------------------- /tenant_schemas/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .template_loader import * 2 | from .test_cache import * 3 | from .test_log import * 4 | from .test_routes import * 5 | from .test_tenants import * 6 | from .test_utils import * 7 | -------------------------------------------------------------------------------- /tenant_schemas/tests/models.py: -------------------------------------------------------------------------------- 1 | from tenant_schemas.models import TenantMixin 2 | 3 | 4 | # as TenantMixin is an abstract model, it needs to be created 5 | class Tenant(TenantMixin): 6 | pass 7 | 8 | class Meta: 9 | app_label = 'tenant_schemas' 10 | 11 | 12 | class NonAutoSyncTenant(TenantMixin): 13 | auto_create_schema = False 14 | 15 | class Meta: 16 | app_label = 'tenant_schemas' 17 | -------------------------------------------------------------------------------- /tenant_schemas/tests/template_loader/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_cached_template_loader import CachedLoaderTests 2 | -------------------------------------------------------------------------------- /tenant_schemas/tests/template_loader/templates/hello.html: -------------------------------------------------------------------------------- 1 | Hello! (Django templates) 2 | -------------------------------------------------------------------------------- /tenant_schemas/tests/template_loader/test_cached_template_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.template.loader import get_template 4 | from django.test import SimpleTestCase, override_settings 5 | 6 | TEMPLATES = [ 7 | { 8 | "BACKEND": "django.template.backends.django.DjangoTemplates", 9 | "DIRS": [os.path.join(os.path.dirname(__file__), "templates")], 10 | "OPTIONS": { 11 | "context_processors": ["django.template.context_processors.request"], 12 | "loaders": [ 13 | ( 14 | "tenant_schemas.template_loaders.CachedLoader", 15 | ("django.template.loaders.filesystem.Loader",), 16 | ) 17 | ], 18 | }, 19 | } 20 | ] 21 | 22 | 23 | class CachedLoaderTests(SimpleTestCase): 24 | @override_settings(TEMPLATES=TEMPLATES) 25 | def test_get_template(self): 26 | template = get_template("hello.html") 27 | self.assertEqual(template.render(), "Hello! (Django templates)\n") 28 | -------------------------------------------------------------------------------- /tenant_schemas/tests/template_loader/test_filesystem_template_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.template.loader import get_template 4 | from django.test import override_settings 5 | from tenant_schemas.test.cases import TenantTestCase 6 | 7 | TEMPLATES = [ 8 | { 9 | "BACKEND": "django.template.backends.django.DjangoTemplates", 10 | "DIRS": [os.path.join(os.path.dirname(__file__), "templates")], 11 | "OPTIONS": { 12 | "context_processors": ["django.template.context_processors.request"], 13 | "loaders": [ 14 | "tenant_schemas.template_loaders.FilesystemLoader", 15 | "django.template.loaders.filesystem.Loader", 16 | ], 17 | }, 18 | } 19 | ] 20 | 21 | MULTITENANT_TEMPLATE_DIRS = [ 22 | os.path.join(os.path.dirname(__file__), "themes/%s/templates") 23 | ] 24 | 25 | 26 | class FilesystemLoaderTenantTests(TenantTestCase): 27 | @override_settings( 28 | TEMPLATES=TEMPLATES, MULTITENANT_TEMPLATE_DIRS=MULTITENANT_TEMPLATE_DIRS 29 | ) 30 | def test_get_template(self): 31 | template = get_template("hello.html") 32 | self.assertEqual(template.render(), "Hello, Tenant! (Django templates)\n") 33 | -------------------------------------------------------------------------------- /tenant_schemas/tests/template_loader/themes/tenant.test.com/templates/hello.html: -------------------------------------------------------------------------------- 1 | Hello, Tenant! (Django templates) 2 | -------------------------------------------------------------------------------- /tenant_schemas/tests/test_apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core.checks import Critical, Error, Warning 3 | from django.test import TestCase 4 | from django.test.utils import override_settings 5 | 6 | from tenant_schemas.apps import best_practice 7 | from tenant_schemas.utils import get_tenant_model 8 | 9 | 10 | class AppConfigTests(TestCase): 11 | 12 | maxDiff = None 13 | 14 | def assertBestPractice(self, expected): 15 | actual = best_practice(apps.get_app_configs()) 16 | self.assertEqual(expected, actual) 17 | 18 | @override_settings() 19 | def test_unset_tenant_apps(self): 20 | from django.conf import settings 21 | del settings.TENANT_APPS 22 | self.assertBestPractice([ 23 | Critical('TENANT_APPS setting not set'), 24 | ]) 25 | 26 | @override_settings() 27 | def test_unset_tenant_model(self): 28 | from django.conf import settings 29 | del settings.TENANT_MODEL 30 | self.assertBestPractice([ 31 | Critical('TENANT_MODEL setting not set'), 32 | ]) 33 | 34 | @override_settings() 35 | def test_unset_shared_apps(self): 36 | from django.conf import settings 37 | del settings.SHARED_APPS 38 | self.assertBestPractice([ 39 | Critical('SHARED_APPS setting not set'), 40 | ]) 41 | 42 | @override_settings(DATABASE_ROUTERS=()) 43 | def test_database_routers(self): 44 | self.assertBestPractice([ 45 | Critical("DATABASE_ROUTERS setting must contain " 46 | "'tenant_schemas.routers.TenantSyncRouter'."), 47 | ]) 48 | 49 | @override_settings(INSTALLED_APPS=[ 50 | 'dpt_test_app', 51 | 'customers', 52 | 'django.contrib.auth', 53 | 'django.contrib.contenttypes', 54 | 'tenant_schemas', 55 | 'django.contrib.sessions', 56 | 'django.contrib.messages', 57 | 'django.contrib.staticfiles', 58 | ]) 59 | def test_tenant_schemas_before_django_installed_apps(self): 60 | self.assertBestPractice([ 61 | Warning("You should put 'tenant_schemas' before any django " 62 | "core applications in INSTALLED_APPS.", 63 | obj="django.conf.settings", 64 | hint="This is necessary to overwrite built-in django " 65 | "management commands with their schema-aware " 66 | "implementations.", 67 | id="tenant_schemas.W001"), 68 | ]) 69 | 70 | @override_settings(INSTALLED_APPS=[ 71 | 'dpt_test_app', 72 | 'customers', 73 | 'tenant_schemas', 74 | 'django.contrib.auth', 75 | 'django.contrib.contenttypes', 76 | 'django.contrib.sessions', 77 | 'django.contrib.messages', 78 | 'django.contrib.staticfiles', 79 | ]) 80 | def test_tenant_schemas_after_custom_apps_in_installed_apps(self): 81 | self.assertBestPractice([]) 82 | 83 | @override_settings(TENANT_APPS=()) 84 | def test_tenant_apps_empty(self): 85 | self.assertBestPractice([ 86 | Error("TENANT_APPS is empty.", 87 | hint="Maybe you don't need this app?", 88 | id="tenant_schemas.E001"), 89 | ]) 90 | 91 | @override_settings(PG_EXTRA_SEARCH_PATHS=['public', 'demo1', 'demo2']) 92 | def test_public_schema_on_extra_search_paths(self): 93 | TenantModel = get_tenant_model() 94 | TenantModel.objects.create( 95 | schema_name='demo1', domain_url='demo1.example.com') 96 | TenantModel.objects.create( 97 | schema_name='demo2', domain_url='demo2.example.com') 98 | self.assertBestPractice([ 99 | Critical("public can not be included on PG_EXTRA_SEARCH_PATHS."), 100 | Critical("Do not include tenant schemas (demo1, demo2) on PG_EXTRA_SEARCH_PATHS."), 101 | ]) 102 | 103 | @override_settings(SHARED_APPS=()) 104 | def test_shared_apps_empty(self): 105 | self.assertBestPractice([ 106 | Warning("SHARED_APPS is empty.", 107 | id="tenant_schemas.W002"), 108 | ]) 109 | 110 | @override_settings(TENANT_APPS=( 111 | 'dpt_test_app', 112 | 'django.contrib.flatpages', 113 | )) 114 | def test_tenant_app_missing_from_install_apps(self): 115 | self.assertBestPractice([ 116 | Error("You have TENANT_APPS that are not in INSTALLED_APPS", 117 | hint=['django.contrib.flatpages'], 118 | id="tenant_schemas.E002"), 119 | ]) 120 | 121 | @override_settings(SHARED_APPS=( 122 | 'tenant_schemas', 123 | 'customers', 124 | 'django.contrib.auth', 125 | 'django.contrib.contenttypes', 126 | 'django.contrib.flatpages', 127 | 'django.contrib.messages', 128 | 'django.contrib.sessions', 129 | 'django.contrib.staticfiles', 130 | )) 131 | def test_shared_app_missing_from_install_apps(self): 132 | self.assertBestPractice([ 133 | Error("You have SHARED_APPS that are not in INSTALLED_APPS", 134 | hint=['django.contrib.flatpages'], 135 | id="tenant_schemas.E003"), 136 | ]) 137 | -------------------------------------------------------------------------------- /tenant_schemas/tests/test_cache.py: -------------------------------------------------------------------------------- 1 | from tenant_schemas.cache import make_key, reverse_key 2 | from tenant_schemas.test.cases import TenantTestCase 3 | 4 | 5 | class CacheHelperTestCase(TenantTestCase): 6 | def test_make_key(self): 7 | key = make_key(key='foo', key_prefix='', version=1) 8 | tenant_prefix = key.split(':')[0] 9 | self.assertEqual(self.tenant.schema_name, tenant_prefix) 10 | 11 | def test_reverse_key(self): 12 | key = 'foo' 13 | self.assertEqual(key, reverse_key(make_key(key=key, key_prefix='', version=1))) 14 | -------------------------------------------------------------------------------- /tenant_schemas/tests/test_log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from mock import patch 3 | 4 | from django.test import TestCase 5 | 6 | from tenant_schemas import log 7 | 8 | 9 | @patch('tenant_schemas.log.connection.tenant', autospec=True, 10 | schema_name='context') 11 | class LoggingFilterTests(TestCase): 12 | 13 | def test_tenant_context_filter(self, mock_connection): 14 | mock_connection.domain_url = 'context.example.com' 15 | filter_ = log.TenantContextFilter() 16 | record = logging.makeLogRecord({}) 17 | res = filter_.filter(record) 18 | self.assertEqual(res, True) 19 | self.assertEqual(record.schema_name, 'context') 20 | self.assertEqual(record.domain_url, 'context.example.com') 21 | 22 | def test_tenant_context_filter_blank_domain_url(self, mock_connection): 23 | filter_ = log.TenantContextFilter() 24 | record = logging.makeLogRecord({}) 25 | res = filter_.filter(record) 26 | self.assertEqual(res, True) 27 | self.assertEqual(record.schema_name, 'context') 28 | self.assertEqual(record.domain_url, '') 29 | -------------------------------------------------------------------------------- /tenant_schemas/tests/test_routes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import six 4 | from django.conf import settings 5 | from django.core.exceptions import DisallowedHost 6 | from django.http import Http404 7 | from django.test.client import RequestFactory 8 | from tenant_schemas.middleware import DefaultTenantMiddleware, TenantMiddleware 9 | from tenant_schemas.tests.models import Tenant 10 | from tenant_schemas.tests.testcases import BaseTestCase 11 | from tenant_schemas.utils import get_public_schema_name 12 | 13 | 14 | class TestDefaultTenantMiddleware(DefaultTenantMiddleware): 15 | DEFAULT_SCHEMA_NAME = "test" 16 | 17 | 18 | class MissingDefaultTenantMiddleware(DefaultTenantMiddleware): 19 | DEFAULT_SCHEMA_NAME = "missing" 20 | 21 | 22 | class RoutesTestCase(BaseTestCase): 23 | @classmethod 24 | def setUpClass(cls): 25 | super(RoutesTestCase, cls).setUpClass() 26 | settings.SHARED_APPS = ("tenant_schemas",) 27 | settings.TENANT_APPS = ( 28 | "dpt_test_app", 29 | "django.contrib.contenttypes", 30 | "django.contrib.auth", 31 | ) 32 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 33 | cls.sync_shared() 34 | cls.public_tenant = Tenant( 35 | domain_url="test.com", schema_name=get_public_schema_name() 36 | ) 37 | cls.public_tenant.save(verbosity=BaseTestCase.get_verbosity()) 38 | 39 | def setUp(self): 40 | super(RoutesTestCase, self).setUp() 41 | self.factory = RequestFactory() 42 | self.tm = TenantMiddleware() 43 | self.dtm = DefaultTenantMiddleware() 44 | 45 | self.tenant_domain = "tenant.test.com" 46 | self.tenant = Tenant(domain_url=self.tenant_domain, schema_name="test") 47 | self.tenant.save(verbosity=BaseTestCase.get_verbosity()) 48 | 49 | self.non_existent_domain = "no-tenant.test.com" 50 | self.non_existent_tenant = Tenant( 51 | domain_url=self.non_existent_domain, schema_name="no-tenant" 52 | ) 53 | 54 | self.url = "/any/path/" 55 | 56 | def test_tenant_routing(self): 57 | request = self.factory.get(self.url, HTTP_HOST=self.tenant_domain) 58 | self.tm.process_request(request) 59 | self.assertEquals(request.path_info, self.url) 60 | self.assertEquals(request.tenant, self.tenant) 61 | 62 | def test_public_schema_routing(self): 63 | request = self.factory.get(self.url, HTTP_HOST=self.public_tenant.domain_url) 64 | self.tm.process_request(request) 65 | self.assertEquals(request.path_info, self.url) 66 | self.assertEquals(request.tenant, self.public_tenant) 67 | 68 | def test_non_existent_tenant_routing(self): 69 | """Raise 404 for unrecognised hostnames.""" 70 | request = self.factory.get( 71 | self.url, HTTP_HOST=self.non_existent_tenant.domain_url 72 | ) 73 | self.assertRaises(Http404, self.tm.process_request, request) 74 | 75 | def test_non_existent_tenant_to_default_schema_routing(self): 76 | """Route unrecognised hostnames to the 'public' tenant.""" 77 | request = self.factory.get( 78 | self.url, HTTP_HOST=self.non_existent_tenant.domain_url 79 | ) 80 | self.dtm.process_request(request) 81 | self.assertEquals(request.path_info, self.url) 82 | self.assertEquals(request.tenant, self.public_tenant) 83 | 84 | def test_non_existent_tenant_custom_middleware(self): 85 | """Route unrecognised hostnames to the 'test' tenant.""" 86 | dtm = TestDefaultTenantMiddleware() 87 | request = self.factory.get( 88 | self.url, HTTP_HOST=self.non_existent_tenant.domain_url 89 | ) 90 | dtm.process_request(request) 91 | self.assertEquals(request.path_info, self.url) 92 | self.assertEquals(request.tenant, self.tenant) 93 | 94 | def test_non_existent_tenant_and_default_custom_middleware(self): 95 | """Route unrecognised hostnames to the 'missing' tenant.""" 96 | dtm = MissingDefaultTenantMiddleware() 97 | request = self.factory.get( 98 | self.url, HTTP_HOST=self.non_existent_tenant.domain_url 99 | ) 100 | self.assertRaises(DisallowedHost, dtm.process_request, request) 101 | -------------------------------------------------------------------------------- /tenant_schemas/tests/test_tenants.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import User 3 | from django.db import connection 4 | from dpt_test_app.models import DummyModel, ModelWithFkToPublicUser 5 | from tenant_schemas.management.commands import tenant_command 6 | from tenant_schemas.test.cases import TenantTestCase 7 | from tenant_schemas.tests.models import NonAutoSyncTenant, Tenant 8 | from tenant_schemas.tests.testcases import BaseTestCase 9 | from tenant_schemas.utils import ( 10 | get_public_schema_name, 11 | get_tenant_model, 12 | schema_context, 13 | schema_exists, 14 | tenant_context, 15 | ) 16 | 17 | try: 18 | # python 2 19 | from StringIO import StringIO 20 | except ImportError: 21 | # python 3 22 | from io import StringIO 23 | 24 | 25 | class TenantDataAndSettingsTest(BaseTestCase): 26 | """ 27 | Tests if the tenant model settings work properly and if data can be saved 28 | and persisted to different tenants. 29 | """ 30 | 31 | @classmethod 32 | def setUpClass(cls): 33 | super(TenantDataAndSettingsTest, cls).setUpClass() 34 | settings.SHARED_APPS = ("tenant_schemas",) 35 | settings.TENANT_APPS = ( 36 | "dpt_test_app", 37 | "django.contrib.contenttypes", 38 | "django.contrib.auth", 39 | ) 40 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 41 | cls.sync_shared() 42 | Tenant(domain_url="test.com", schema_name=get_public_schema_name()).save( 43 | verbosity=cls.get_verbosity() 44 | ) 45 | 46 | def test_tenant_schema_is_created(self): 47 | """ 48 | When saving a tenant, it's schema should be created. 49 | """ 50 | tenant = Tenant(domain_url="something.test.com", schema_name="test") 51 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 52 | 53 | self.assertTrue(schema_exists(tenant.schema_name)) 54 | 55 | def test_non_auto_sync_tenant(self): 56 | """ 57 | When saving a tenant that has the flag auto_create_schema as 58 | False, the schema should not be created when saving the tenant. 59 | """ 60 | self.assertFalse(schema_exists("non_auto_sync_tenant")) 61 | tenant = NonAutoSyncTenant( 62 | domain_url="something.test.com", schema_name="non_auto_sync_tenant" 63 | ) 64 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 65 | self.assertFalse(schema_exists(tenant.schema_name)) 66 | 67 | def test_sync_tenant(self): 68 | """ 69 | When editing an existing tenant, all data should be kept. 70 | """ 71 | tenant = Tenant(domain_url="something.test.com", schema_name="test") 72 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 73 | 74 | # go to tenant's path 75 | connection.set_tenant(tenant) 76 | 77 | # add some data 78 | DummyModel(name="Schemas are").save() 79 | DummyModel(name="awesome!").save() 80 | 81 | # edit tenant 82 | connection.set_schema_to_public() 83 | tenant.domain_url = "example.com" 84 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 85 | 86 | connection.set_tenant(tenant) 87 | 88 | # test if data is still there 89 | self.assertEqual(DummyModel.objects.count(), 2) 90 | 91 | def test_auto_drop_schema(self): 92 | """ 93 | When deleting a tenant with auto_drop_schema=True, it should delete 94 | the schema associated with the tenant. 95 | """ 96 | self.assertFalse(schema_exists("auto_drop_tenant")) 97 | Tenant.auto_drop_schema = True 98 | tenant = Tenant(domain_url="something.test.com", schema_name="auto_drop_tenant") 99 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 100 | self.assertTrue(schema_exists(tenant.schema_name)) 101 | cursor = connection.cursor() 102 | 103 | # Force pending trigger events to be executed 104 | cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") 105 | 106 | tenant.delete() 107 | self.assertFalse(schema_exists(tenant.schema_name)) 108 | Tenant.auto_drop_schema = False 109 | 110 | def test_auto_drop_schema_bulk_delete(self): 111 | """ 112 | When bulk deleting tenants, it should also drop the schemas of 113 | tenants that have auto_drop_schema set to True. 114 | """ 115 | Tenant.auto_drop_schema = True 116 | schemas = ["auto_drop_schema1", "auto_drop_schema2"] 117 | for schema in schemas: 118 | self.assertFalse(schema_exists(schema)) 119 | tenant = Tenant(domain_url="%s.test.com" % schema, schema_name=schema) 120 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 121 | self.assertTrue(schema_exists(tenant.schema_name)) 122 | 123 | # Force pending trigger events to be executed 124 | cursor = connection.cursor() 125 | cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") 126 | 127 | # get a queryset of our 2 tenants and do a bulk delete 128 | Tenant.objects.filter(schema_name__in=schemas).delete() 129 | 130 | # verify that the schemas where deleted 131 | for schema in schemas: 132 | self.assertFalse(schema_exists(schema)) 133 | 134 | Tenant.auto_drop_schema = False 135 | 136 | def test_switching_search_path(self): 137 | tenant1 = Tenant(domain_url="something.test.com", schema_name="tenant1") 138 | tenant1.save(verbosity=BaseTestCase.get_verbosity()) 139 | 140 | connection.set_schema_to_public() 141 | tenant2 = Tenant(domain_url="example.com", schema_name="tenant2") 142 | tenant2.save(verbosity=BaseTestCase.get_verbosity()) 143 | 144 | # go to tenant1's path 145 | connection.set_tenant(tenant1) 146 | 147 | # add some data, 2 DummyModels for tenant1 148 | DummyModel(name="Schemas are").save() 149 | DummyModel(name="awesome!").save() 150 | 151 | # switch temporarily to tenant2's path 152 | with tenant_context(tenant2): 153 | # add some data, 3 DummyModels for tenant2 154 | DummyModel(name="Man,").save() 155 | DummyModel(name="testing").save() 156 | DummyModel(name="is great!").save() 157 | 158 | # we should be back to tenant1's path, test what we have 159 | self.assertEqual(2, DummyModel.objects.count()) 160 | 161 | # switch back to tenant2's path 162 | with tenant_context(tenant2): 163 | self.assertEqual(3, DummyModel.objects.count()) 164 | 165 | def test_switching_tenant_without_previous_tenant(self): 166 | tenant = Tenant(domain_url="something.test.com", schema_name="test") 167 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 168 | 169 | connection.tenant = None 170 | with tenant_context(tenant): 171 | DummyModel(name="No exception please").save() 172 | 173 | connection.tenant = None 174 | with schema_context(tenant.schema_name): 175 | DummyModel(name="Survived it!").save() 176 | 177 | 178 | class TenantSyncTest(BaseTestCase): 179 | """ 180 | Tests if the shared apps and the tenant apps get synced correctly 181 | depending on if the public schema or a tenant is being synced. 182 | """ 183 | 184 | MIGRATION_TABLE_SIZE = 1 185 | 186 | def test_shared_apps_does_not_sync_tenant_apps(self): 187 | """ 188 | Tests that if an app is in SHARED_APPS, it does not get synced to 189 | the a tenant schema. 190 | """ 191 | settings.SHARED_APPS = ( 192 | "tenant_schemas", # 2 tables 193 | "django.contrib.auth", # 6 tables 194 | "django.contrib.contenttypes", 195 | ) # 1 table 196 | settings.TENANT_APPS = ("django.contrib.sessions",) 197 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 198 | self.sync_shared() 199 | 200 | shared_tables = self.get_tables_list_in_schema(get_public_schema_name()) 201 | self.assertEqual(2 + 6 + 1 + self.MIGRATION_TABLE_SIZE, len(shared_tables)) 202 | self.assertNotIn("django_session", shared_tables) 203 | 204 | def test_tenant_apps_does_not_sync_shared_apps(self): 205 | """ 206 | Tests that if an app is in TENANT_APPS, it does not get synced to 207 | the public schema. 208 | """ 209 | settings.SHARED_APPS = ( 210 | "tenant_schemas", 211 | "django.contrib.auth", 212 | "django.contrib.contenttypes", 213 | ) 214 | settings.TENANT_APPS = ("django.contrib.sessions",) # 1 table 215 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 216 | self.sync_shared() 217 | tenant = Tenant(domain_url="arbitrary.test.com", schema_name="test") 218 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 219 | 220 | tenant_tables = self.get_tables_list_in_schema(tenant.schema_name) 221 | self.assertEqual(1 + self.MIGRATION_TABLE_SIZE, len(tenant_tables)) 222 | self.assertIn("django_session", tenant_tables) 223 | 224 | def test_tenant_apps_and_shared_apps_can_have_the_same_apps(self): 225 | """ 226 | Tests that both SHARED_APPS and TENANT_APPS can have apps in common. 227 | In this case they should get synced to both tenant and public schemas. 228 | """ 229 | settings.SHARED_APPS = ( 230 | "tenant_schemas", # 2 tables 231 | "django.contrib.auth", # 6 tables 232 | "django.contrib.contenttypes", # 1 table 233 | "django.contrib.sessions", 234 | ) # 1 table 235 | settings.TENANT_APPS = ("django.contrib.sessions",) # 1 table 236 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 237 | self.sync_shared() 238 | tenant = Tenant(domain_url="arbitrary.test.com", schema_name="test") 239 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 240 | 241 | shared_tables = self.get_tables_list_in_schema(get_public_schema_name()) 242 | tenant_tables = self.get_tables_list_in_schema(tenant.schema_name) 243 | self.assertEqual(2 + 6 + 1 + 1 + self.MIGRATION_TABLE_SIZE, len(shared_tables)) 244 | self.assertIn("django_session", shared_tables) 245 | self.assertEqual(1 + self.MIGRATION_TABLE_SIZE, len(tenant_tables)) 246 | self.assertIn("django_session", tenant_tables) 247 | 248 | def test_content_types_is_not_mandatory(self): 249 | """ 250 | Tests that even if content types is in SHARED_APPS, it's 251 | not required in TENANT_APPS. 252 | """ 253 | settings.SHARED_APPS = ( 254 | "tenant_schemas", # 2 tables 255 | "django.contrib.contenttypes", 256 | ) # 1 table 257 | settings.TENANT_APPS = ("django.contrib.sessions",) # 1 table 258 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 259 | self.sync_shared() 260 | tenant = Tenant(domain_url="something.test.com", schema_name="test") 261 | tenant.save(verbosity=BaseTestCase.get_verbosity()) 262 | 263 | shared_tables = self.get_tables_list_in_schema(get_public_schema_name()) 264 | tenant_tables = self.get_tables_list_in_schema(tenant.schema_name) 265 | self.assertEqual(2 + 1 + self.MIGRATION_TABLE_SIZE, len(shared_tables)) 266 | self.assertIn("django_session", tenant_tables) 267 | self.assertEqual(1 + self.MIGRATION_TABLE_SIZE, len(tenant_tables)) 268 | self.assertIn("django_session", tenant_tables) 269 | 270 | 271 | class TenantCommandTest(BaseTestCase): 272 | def test_command(self): 273 | """ 274 | Tests that tenant_command is capable of wrapping commands 275 | and its parameters. 276 | """ 277 | settings.SHARED_APPS = ( 278 | "tenant_schemas", 279 | "django.contrib.contenttypes", 280 | ) 281 | settings.TENANT_APPS = () 282 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 283 | self.sync_shared() 284 | Tenant(domain_url="localhost", schema_name="public").save( 285 | verbosity=BaseTestCase.get_verbosity() 286 | ) 287 | 288 | out = StringIO() 289 | tenant_command.Command().handle( 290 | "dumpdata", 291 | get_public_schema_name(), 292 | "tenant_schemas", 293 | natural_foreign=True, 294 | stdout=out, 295 | ) 296 | self.assertJSONEqual( 297 | out.getvalue(), 298 | [ 299 | { 300 | "fields": {"domain_url": "localhost", "schema_name": "public"}, 301 | "model": "tenant_schemas.tenant", 302 | "pk": 1, 303 | } 304 | ], 305 | ) 306 | 307 | 308 | class SharedAuthTest(BaseTestCase): 309 | @classmethod 310 | def setUpClass(cls): 311 | super(SharedAuthTest, cls).setUpClass() 312 | settings.SHARED_APPS = ( 313 | "tenant_schemas", 314 | "django.contrib.auth", 315 | "django.contrib.contenttypes", 316 | ) 317 | settings.TENANT_APPS = ("dpt_test_app",) 318 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 319 | cls.sync_shared() 320 | Tenant(domain_url="test.com", schema_name=get_public_schema_name()).save( 321 | verbosity=cls.get_verbosity() 322 | ) 323 | 324 | # Create a tenant 325 | cls.tenant = Tenant(domain_url="tenant.test.com", schema_name="tenant") 326 | cls.tenant.save(verbosity=cls.get_verbosity()) 327 | 328 | # Create some users 329 | with schema_context( 330 | get_public_schema_name() 331 | ): # this could actually also be executed inside a tenant 332 | cls.user1 = User(username="arbitrary-1", email="arb1@test.com") 333 | cls.user1.save() 334 | cls.user2 = User(username="arbitrary-2", email="arb2@test.com") 335 | cls.user2.save() 336 | 337 | # Create instances on the tenant that point to the users on public 338 | with tenant_context(cls.tenant): 339 | cls.d1 = ModelWithFkToPublicUser(user=cls.user1) 340 | cls.d1.save() 341 | cls.d2 = ModelWithFkToPublicUser(user=cls.user2) 342 | cls.d2.save() 343 | 344 | def test_cross_schema_constraint_gets_created(self): 345 | """ 346 | Tests that a foreign key constraint gets created even for cross schema references. 347 | """ 348 | sql = """ 349 | SELECT 350 | tc.constraint_name, tc.table_name, kcu.column_name, 351 | ccu.table_name AS foreign_table_name, 352 | ccu.column_name AS foreign_column_name 353 | FROM 354 | information_schema.table_constraints AS tc 355 | JOIN information_schema.key_column_usage AS kcu 356 | ON tc.constraint_name = kcu.constraint_name 357 | JOIN information_schema.constraint_column_usage AS ccu 358 | ON ccu.constraint_name = tc.constraint_name 359 | WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name=%s 360 | """ 361 | cursor = connection.cursor() 362 | cursor.execute(sql, (ModelWithFkToPublicUser._meta.db_table,)) 363 | fk_constraints = cursor.fetchall() 364 | self.assertEqual(1, len(fk_constraints)) 365 | 366 | # The foreign key should reference the primary key of the user table 367 | fk = fk_constraints[0] 368 | self.assertEqual(User._meta.db_table, fk[3]) 369 | self.assertEqual("id", fk[4]) 370 | 371 | def test_direct_relation_to_public(self): 372 | """ 373 | Tests that a forward relationship through a foreign key to public from a model inside TENANT_APPS works. 374 | """ 375 | with tenant_context(self.tenant): 376 | self.assertEqual( 377 | User.objects.get(pk=self.user1.id), 378 | ModelWithFkToPublicUser.objects.get(pk=self.d1.id).user, 379 | ) 380 | self.assertEqual( 381 | User.objects.get(pk=self.user2.id), 382 | ModelWithFkToPublicUser.objects.get(pk=self.d2.id).user, 383 | ) 384 | 385 | def test_reverse_relation_to_public(self): 386 | """ 387 | Tests that a reverse relationship through a foreign keys to public from a model inside TENANT_APPS works. 388 | """ 389 | with tenant_context(self.tenant): 390 | users = User.objects.all().select_related().order_by("id") 391 | self.assertEqual( 392 | ModelWithFkToPublicUser.objects.get(pk=self.d1.id), 393 | users[0].modelwithfktopublicuser_set.all()[:1].get(), 394 | ) 395 | self.assertEqual( 396 | ModelWithFkToPublicUser.objects.get(pk=self.d2.id), 397 | users[1].modelwithfktopublicuser_set.all()[:1].get(), 398 | ) 399 | 400 | 401 | class TenantTestCaseTest(BaseTestCase, TenantTestCase): 402 | """ 403 | Tests that the tenant created inside TenantTestCase persists on 404 | all functions. 405 | """ 406 | 407 | def test_tenant_survives_after_method1(self): 408 | # There is one tenant in the database, the one created by TenantTestCase 409 | self.assertEquals(1, get_tenant_model().objects.all().count()) 410 | 411 | def test_tenant_survives_after_method2(self): 412 | # The same tenant still exists even after the previous method call 413 | self.assertEquals(1, get_tenant_model().objects.all().count()) 414 | -------------------------------------------------------------------------------- /tenant_schemas/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import sys 4 | import types 5 | 6 | from django.apps import AppConfig 7 | from django.test import TestCase 8 | 9 | from tenant_schemas import utils 10 | 11 | 12 | class AppLabelsTestCase(TestCase): 13 | def setUp(self): 14 | self._modules = set() 15 | 16 | def tearDown(self): 17 | for name in self._modules: 18 | sys.modules.pop(name, None) 19 | 20 | def set_up_module(self, whole_name): 21 | parts = whole_name.split('.') 22 | name = '' 23 | for part in parts: 24 | name += ('.%s' % part) if name else part 25 | module = types.ModuleType(name) 26 | module.__path__ = ['/tmp'] 27 | self._modules.add(name) 28 | sys.modules[name] = module 29 | return sys.modules[whole_name] 30 | 31 | def test_app_labels(self): 32 | """ 33 | Verifies that app_labels handle Django 1.7+ AppConfigs properly. 34 | https://docs.djangoproject.com/en/1.7/ref/applications/ 35 | """ 36 | self.set_up_module('example1') 37 | apps = self.set_up_module('example2.apps') 38 | 39 | # set up AppConfig on the `test_app.apps` module 40 | class Example2AppConfig(AppConfig): 41 | name = 'example2' 42 | label = 'example2_app' # with different name 43 | path = '/tmp' # for whatever reason path is required 44 | 45 | apps.Example2AppConfig = Example2AppConfig 46 | 47 | self.assertEqual( 48 | utils.app_labels([ 49 | 'example1', 50 | 'example2.apps.Example2AppConfig' 51 | ]), 52 | ['example1', 'example2_app'], 53 | ) 54 | -------------------------------------------------------------------------------- /tenant_schemas/tests/testcases.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.conf import settings 4 | from django.core.management import call_command 5 | from django.db import connection 6 | from django.test import TestCase 7 | 8 | from tenant_schemas.utils import get_public_schema_name 9 | 10 | 11 | class BaseTestCase(TestCase): 12 | """ 13 | Base test case that comes packed with overloaded INSTALLED_APPS, 14 | custom public tenant, and schemas cleanup on tearDown. 15 | """ 16 | @classmethod 17 | def setUpClass(cls): 18 | settings.TENANT_MODEL = 'tenant_schemas.Tenant' 19 | settings.SHARED_APPS = ('tenant_schemas', ) 20 | settings.TENANT_APPS = ('dpt_test_app', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.auth', ) 23 | settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS 24 | if '.test.com' not in settings.ALLOWED_HOSTS: 25 | settings.ALLOWED_HOSTS += ['.test.com'] 26 | 27 | # Django calls syncdb by default for the test database, but we want 28 | # a blank public schema for this set of tests. 29 | connection.set_schema_to_public() 30 | cursor = connection.cursor() 31 | cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE; CREATE SCHEMA %s;' 32 | % (get_public_schema_name(), get_public_schema_name())) 33 | super(BaseTestCase, cls).setUpClass() 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | super(BaseTestCase, cls).tearDownClass() 38 | 39 | if '.test.com' in settings.ALLOWED_HOSTS: 40 | settings.ALLOWED_HOSTS.remove('.test.com') 41 | 42 | def setUp(self): 43 | connection.set_schema_to_public() 44 | super(BaseTestCase, self).setUp() 45 | 46 | @classmethod 47 | def get_verbosity(self): 48 | for s in reversed(inspect.stack()): 49 | options = s[0].f_locals.get('options') 50 | if isinstance(options, dict): 51 | return int(options['verbosity']) - 2 52 | return 1 53 | 54 | @classmethod 55 | def get_tables_list_in_schema(cls, schema_name): 56 | cursor = connection.cursor() 57 | sql = """SELECT table_name FROM information_schema.tables 58 | WHERE table_schema = %s""" 59 | cursor.execute(sql, (schema_name, )) 60 | return [row[0] for row in cursor.fetchall()] 61 | 62 | @classmethod 63 | def sync_shared(cls): 64 | call_command('migrate_schemas', 65 | schema_name=get_public_schema_name(), 66 | interactive=False, 67 | verbosity=cls.get_verbosity(), 68 | run_syncdb=True) 69 | -------------------------------------------------------------------------------- /tenant_schemas/urlresolvers.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse as reverse_default 2 | from django.utils.functional import lazy 3 | from tenant_schemas.utils import clean_tenant_url 4 | 5 | 6 | def reverse(viewname, urlconf=None, args=None, kwargs=None, prefix=None, 7 | current_app=None): 8 | url = reverse_default( 9 | viewname=viewname, 10 | urlconf=urlconf, 11 | args=args, 12 | kwargs=kwargs, 13 | current_app=current_app 14 | ) 15 | return clean_tenant_url(url) 16 | 17 | reverse_lazy = lazy(reverse, str) 18 | -------------------------------------------------------------------------------- /tenant_schemas/utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from django.conf import settings 4 | from django.db import connection 5 | 6 | try: 7 | from django.apps import apps, AppConfig 8 | get_model = apps.get_model 9 | except ImportError: 10 | from django.db.models.loading import get_model 11 | AppConfig = None 12 | from django.core import mail 13 | 14 | 15 | @contextmanager 16 | def schema_context(schema_name): 17 | previous_tenant = connection.tenant 18 | try: 19 | connection.set_schema(schema_name) 20 | yield 21 | finally: 22 | if previous_tenant is None: 23 | connection.set_schema_to_public() 24 | else: 25 | connection.set_tenant(previous_tenant) 26 | 27 | 28 | @contextmanager 29 | def tenant_context(tenant): 30 | previous_tenant = connection.tenant 31 | try: 32 | connection.set_tenant(tenant) 33 | yield 34 | finally: 35 | if previous_tenant is None: 36 | connection.set_schema_to_public() 37 | else: 38 | connection.set_tenant(previous_tenant) 39 | 40 | 41 | def get_tenant_model(): 42 | return get_model(*settings.TENANT_MODEL.split(".")) 43 | 44 | 45 | def get_public_schema_name(): 46 | return getattr(settings, 'PUBLIC_SCHEMA_NAME', 'public') 47 | 48 | 49 | def get_limit_set_calls(): 50 | return getattr(settings, 'TENANT_LIMIT_SET_CALLS', False) 51 | 52 | 53 | def clean_tenant_url(url_string): 54 | """ 55 | Removes the TENANT_TOKEN from a particular string 56 | """ 57 | if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF'): 58 | if (settings.PUBLIC_SCHEMA_URLCONF and 59 | url_string.startswith(settings.PUBLIC_SCHEMA_URLCONF)): 60 | url_string = url_string[len(settings.PUBLIC_SCHEMA_URLCONF):] 61 | return url_string 62 | 63 | 64 | def remove_www_and_dev(hostname): 65 | """ 66 | Legacy function - just in case someone is still using the old name 67 | """ 68 | return remove_www(hostname) 69 | 70 | 71 | def remove_www(hostname): 72 | """ 73 | Removes www. from the beginning of the address. Only for 74 | routing purposes. www.test.com/login/ and test.com/login/ should 75 | find the same tenant. 76 | """ 77 | if hostname.startswith("www."): 78 | return hostname[4:] 79 | 80 | return hostname 81 | 82 | 83 | def django_is_in_test_mode(): 84 | """ 85 | I know this is very ugly! I'm looking for more elegant solutions. 86 | See: http://stackoverflow.com/questions/6957016/detect-django-testing-mode 87 | """ 88 | return hasattr(mail, 'outbox') 89 | 90 | 91 | def schema_exists(schema_name): 92 | cursor = connection.cursor() 93 | 94 | # check if this schema already exists in the db 95 | sql = 'SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_namespace WHERE LOWER(nspname) = LOWER(%s))' 96 | cursor.execute(sql, (schema_name, )) 97 | 98 | row = cursor.fetchone() 99 | if row: 100 | exists = row[0] 101 | else: 102 | exists = False 103 | 104 | cursor.close() 105 | 106 | return exists 107 | 108 | 109 | def app_labels(apps_list): 110 | """ 111 | Returns a list of app labels of the given apps_list, now properly handles 112 | new Django 1.7+ application registry. 113 | 114 | https://docs.djangoproject.com/en/1.8/ref/applications/#django.apps.AppConfig.label 115 | """ 116 | if AppConfig is None: 117 | return [app.split('.')[-1] for app in apps_list] 118 | return [AppConfig.create(app).label for app in apps_list] 119 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37,38,39}-dj{22,31,32}-{standard,parallel} 4 | skip_missing_interpreters = true 5 | 6 | [travis:env] 7 | DJANGO = 8 | 2.2: dj22-{standard,parallel} 9 | 3.1: dj31-{standard,parallel} 10 | 3.2: dj32-{standard,parallel} 11 | 12 | [testenv] 13 | usedevelop = true 14 | pre = true 15 | deps = 16 | coverage 17 | mock 18 | tblib 19 | dj22: Django>=2.2a1,<3.0 20 | dj31: Django>=3.1a1,<3.2 21 | dj32: Django>=3.2a1,<3.3 22 | 23 | docker = 24 | pg120: postgres:12 25 | dockerenv = 26 | POSTGRES_USER=dpt_test_project 27 | POSTGRES_PASSWORD=dpt_test_project 28 | 29 | changedir = dpt_test_project 30 | 31 | passenv = PGNAME PGUSER PGPASSWORD PGHOST PGPORT 32 | 33 | setenv = 34 | standard: MIGRATION_EXECUTOR=standard 35 | parallel: MIGRATION_EXECUTOR=parallel 36 | 37 | commands = 38 | coverage run manage.py test -v 2 --noinput {posargs:tenant_schemas} 39 | coverage report -m --include=../tenant_schemas/* 40 | --------------------------------------------------------------------------------