├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── README.rst ├── django-multitenancy-arch.png ├── django_multitenancy ├── __init__.py ├── constants.py ├── helpers.py ├── middleware │ ├── __init__.py │ └── base.py └── mixins.py ├── requirements.txt ├── setup.py ├── testdjangoproject ├── manage.py ├── pytest.ini ├── tenantapp │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── tenants │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── urls.py │ └── views.py ├── testdjangoproject │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── tests │ ├── __init__.py │ └── tenants │ ├── __init__.py │ ├── test_models.py │ └── test_views.py ├── tests ├── helpers │ ├── __init__.py │ └── test_get_tenant.py ├── middleware │ └── test_base.py └── test_example.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pavel Bitiukov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | python3 -m pip install -r requirements.txt 3 | 4 | test: 5 | python3 -m tox -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Multitenancy 2 | 3 | ![Django Multitenancy](django-multitenancy-arch.png) 4 | 5 | django-multitenancy is a python library for Django applications that enables multitenancy with physical tenant data isolation (separate database instance per tenant) in your web applications. 6 | 7 | Multitenancy is a software architecture where a single instance of an application serves multiple tenants, or clients, in a shared environment. In this model, tenants share the same application and underlying infrastructure, but their data is logically/physically isolated, allowing each tenant to operate as if they have their own dedicated instance. Typically, multitenancy is employed to optimize resource utilization and reduce operational costs by serving a large user base with a single, shared codebase and set of resources. 8 | 9 | In the solution with a shared common database and separate database per tenant, a central database stores common data shared among all tenants, promoting resource efficiency, while each tenant has its own dedicated database for customized and isolated data management. This architecture provides a balance between shared resource benefits and individual tenant autonomy, making it well-suited for applications where tenants require varying levels of customization and data isolation. However, it introduces increased complexity in database management as each tenant's database needs separate administration. 10 | 11 | 12 | ## Table of Contents 13 | 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | - [Configuring Multitenancy](#configuring-multitenancy) 17 | - [Defining Tenant-Specific Models](#defining-tenant-specific-models) 18 | - [Accessing Tenant-Specific Data](#accessing-tenant-specific-data) 19 | - [Example](#example) 20 | - [Contributing](#contributing) 21 | - [License](#license) 22 | 23 | ## Installation 24 | 25 | To install the `django-multitenancy-plus` package, use the following pip command: 26 | 27 | ```bash 28 | pip install django-multitenancy-plus 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### Defining Tenant-Specific Models 34 | 35 | Create tenant app and add tenant model by inheriting from `django_multitenancy.mixins.TenantMixin`: 36 | 37 | ```python 38 | from django_multitenancy.mixins import TenantMixin 39 | 40 | 41 | class Tenant(TenantMixin): 42 | name = models.CharField(max_length=100, null=False) 43 | ``` 44 | 45 | Define tenant model in settings.py: 46 | ```python 47 | TENANT_MODEL = "tenants.Tenant" 48 | ``` 49 | 50 | In your Django project's settings, add `tenants` to your `SHARED_APPS`: 51 | 52 | ```python 53 | # data for this list of apps would exist in every database schema/database instance 54 | SHARED_APPS = [ 55 | "django.contrib.admin", 56 | "django.contrib.auth", 57 | "django.contrib.contenttypes", 58 | "django.contrib.sessions", 59 | "django.contrib.messages", 60 | "django.contrib.staticfiles", 61 | "tenants", 62 | ] 63 | 64 | # data for this list of apps would exist in single database schema/database instance 65 | TENANT_APPS = [ 66 | "tenantapp", 67 | ] 68 | 69 | # Application definition 70 | INSTALLED_APPS = SHARED_APPS + [app for app in TENANT_APPS if app not in SHARED_APPS] 71 | ``` 72 | 73 | Include the `MultiTenantMiddleware` in your `MIDDLEWARE`: 74 | 75 | ```python 76 | MIDDLEWARE = [ 77 | # make it first 78 | "django_multitenancy.middleware.MultiTenantMiddleware", 79 | 80 | # ... 81 | ] 82 | ``` 83 | 84 | ### Accessing Tenant-Specific Data 85 | 86 | To access tenant-specific data, use the contextvar `TENANT_VAR`: 87 | 88 | ```python 89 | from django.http import HttpResponse, JsonResponse 90 | from django_multitenancy.helpers import get_tenant_model, TENANT_VAR 91 | 92 | 93 | def get_tanent_model_view(request): 94 | model = get_tenant_model() 95 | html = f"

{model.__name__}

{request}

" 96 | return HttpResponse(html, content_type="text/html", status=200) 97 | 98 | 99 | def get_tenant_var_view(request): 100 | html = f"tenant: {TENANT_VAR.get()}" 101 | return HttpResponse(html, content_type="text/html", status=200) 102 | 103 | 104 | def tenant_api__get_tenant_id(request): 105 | tenant = TENANT_VAR.get() 106 | return JsonResponse({ 107 | "tenant_id": tenant.id, 108 | }) 109 | ``` 110 | 111 | 112 | For a more detailed example, check the [examples](examples/) directory in this repository. 113 | 114 | ## Contributing 115 | 116 | We welcome contributions! Please follow our [contribution guidelines](CONTRIBUTING.md) to get started. 117 | 118 | ## License 119 | 120 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 121 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | django-multitenancy 3 | ================ 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-multitenancy.svg 6 | :target: https://pypi.org/project/django-multitenancy 7 | :alt: PyPI version 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/django-multitenancy.svg 10 | :target: https://pypi.org/project/django-multitenancy 11 | :alt: Python versions 12 | 13 | A library that implements usage of multiple databases for multitenant django applications. 14 | 15 | ---- 16 | 17 | Concept 18 | ------- 19 | Software multitenancy is a software architecture in which a single 20 | instance of software runs on a server and serves multiple tenants. 21 | Systems designed in such manner are "shared" (rather than "dedicated" or "isolated"). 22 | A tenant is a group of users who share a common access with specific privileges to the 23 | software instance. With a multitenant architecture, a software application is designed to 24 | provide every tenant a dedicated share of the instance - including its data, configuration, 25 | user management, tenant individual functionality and non-functional properties. 26 | 27 | Multitenancy contrasts with multi-instance architectures, where separate software instances 28 | operate on behalf of different tenants.[1] 29 | 1111 30 | 31 | Features 32 | -------- 33 | 34 | * List of features 35 | 36 | 37 | Requirements 38 | ------------ 39 | 40 | * django>=4,<4.3 41 | 42 | 43 | Installation 44 | ------------ 45 | 46 | Install `django-multitenancy` via `pip`_ from `PyPI`_:: 47 | 48 | $ pip install django-multitenancy 49 | 50 | Usage 51 | ----- 52 | 53 | * 54 | 55 | Contributing 56 | ------------ 57 | Contributions are very welcome. Tests can be run with `tox`_, please ensure 58 | the coverage at least stays the same before you submit a pull request. 59 | 60 | License 61 | ------- 62 | 63 | Distributed under the terms of the `MIT`_ license, "django-multitenancy" is free and open source software 64 | 65 | 66 | Issues 67 | ------ 68 | 69 | If you encounter any problems, please `file an issue`_ along with a detailed description. 70 | 71 | .. _`MIT`: http://opensource.org/licenses/MIT 72 | .. _`file an issue`: https://github.com/bp72/django-multitenancy/issues 73 | .. _`tox`: https://tox.readthedocs.io/en/latest/ 74 | .. _`pip`: https://pypi.org/project/pip/ 75 | .. _`PyPI`: https://pypi.org/project 76 | -------------------------------------------------------------------------------- /django-multitenancy-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/django-multitenancy-arch.png -------------------------------------------------------------------------------- /django_multitenancy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/django_multitenancy/__init__.py -------------------------------------------------------------------------------- /django_multitenancy/constants.py: -------------------------------------------------------------------------------- 1 | POSTGRESQL_MAX_NAME_LENGTH: int = 63 2 | MAX_WEB_DOMAIN_LENGTH: int = 253 3 | -------------------------------------------------------------------------------- /django_multitenancy/helpers.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | 6 | try: 7 | from django.apps import apps 8 | get_model = apps.get_model 9 | except ImportError: 10 | from django.db.models.loading import get_model 11 | 12 | 13 | TENANT_VAR = ContextVar("django_multitenancy_tenant") 14 | 15 | 16 | def get_tenant_model(): 17 | return get_model(settings.TENANT_MODEL) 18 | 19 | 20 | def get_tenant(domain_name): 21 | """ 22 | Get Tenant Instance by domain name. It raises 2 kind of exceptions: 23 | 1. ImproperlyConfigured in case of any problem with get_model 24 | 2. TenantDoesNotExist if there is no tenant by domain 25 | """ 26 | try: 27 | model = get_tenant_model() 28 | tenant = model.objects.get(domain_name=domain_name) 29 | return tenant 30 | except ImproperlyConfigured as E: 31 | raise Exception(E) 32 | except model.DoesNotExist: 33 | raise model.TenantDoesNotExist(domain_name) 34 | -------------------------------------------------------------------------------- /django_multitenancy/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from django_multitenancy.middleware.base import MultiTenantMiddleware 2 | 3 | __all__ = ['MultiTenantMiddleware',] 4 | -------------------------------------------------------------------------------- /django_multitenancy/middleware/base.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.utils.deprecation import MiddlewareMixin 3 | from django_multitenancy.helpers import get_tenant, TENANT_VAR 4 | from django_multitenancy.mixins import TenantMixin 5 | 6 | 7 | class MultiTenantMiddleware(MiddlewareMixin): 8 | TENANT_DOESNOT_EXIST_EXCEPTION = Http404 9 | 10 | def __init__(self, get_response): 11 | super().__init__(get_response) 12 | self.token = None 13 | 14 | @staticmethod 15 | def get_hostname(request) -> str: 16 | return request.get_host().split(':')[0].lstrip("www.") 17 | 18 | def process_request(self, request): 19 | host = self.get_hostname(request) 20 | try: 21 | tenant = get_tenant(host) 22 | self.token = TENANT_VAR.set(tenant) 23 | except TenantMixin.TenantDoesNotExist: 24 | raise self.TENANT_DOESNOT_EXIST_EXCEPTION(f"Tenant does not exist for host '{host}'") 25 | 26 | def process_response(self, request, response): 27 | if self.token is not None: 28 | TENANT_VAR.reset(self.token) 29 | return response 30 | -------------------------------------------------------------------------------- /django_multitenancy/mixins.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_multitenancy.constants import POSTGRESQL_MAX_NAME_LENGTH, MAX_WEB_DOMAIN_LENGTH 4 | 5 | 6 | class TenantMixin(models.Model): 7 | 8 | class TenantDoesNotExist(Exception): 9 | pass 10 | 11 | database_name = models.CharField(max_length=POSTGRESQL_MAX_NAME_LENGTH, unique=True) 12 | domain_name = models.CharField(max_length=MAX_WEB_DOMAIN_LENGTH, unique=True) 13 | 14 | class Meta: 15 | abstract = True 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django==4.2.7 2 | pytest==7.4.3 3 | pytest-django==4.7.0 4 | tox==4.11.3 5 | pytest-mock==3.12.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name='django-multitenancy-plus', 6 | version='0.1.0', 7 | packages=['django_multitenancy', 'django_multitenancy/middleware'], 8 | url='', 9 | license='MIT', 10 | author='Pavel Bityukov', 11 | author_email='pavleg.bityukov@gmail.com', 12 | maintainer='Pavel Bityukov', 13 | maintainer_email='pavleg.bityukov@gmail.com', 14 | description='', 15 | python_requires='>=3.8', 16 | install_requires=['django>=4,<4.3'], 17 | ) 18 | -------------------------------------------------------------------------------- /testdjangoproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjangoproject.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /testdjangoproject/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = testdjangoproject.settings 3 | -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/tenantapp/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from tenantapp.models import House 3 | 4 | 5 | @admin.register(House) 6 | class HouseAdmin(admin.ModelAdmin): 7 | list_display = ("id", "addr") 8 | -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TenantappConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'tenantapp' 7 | -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-12-07 11:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='House', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('addr', models.CharField(max_length=32)), 19 | ('desc', models.TextField()), 20 | ('created_at', models.DateTimeField(auto_now_add=True)), 21 | ('updated_at', models.DateTimeField(auto_now=True)), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/tenantapp/migrations/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class House(models.Model): 5 | addr = models.CharField(max_length=32) 6 | desc = models.TextField() 7 | created_at = models.DateTimeField(auto_now_add=True) 8 | updated_at = models.DateTimeField(auto_now=True) 9 | 10 | def __str__(self): 11 | return f"House: {self.addr}" 12 | -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /testdjangoproject/tenantapp/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http.request import HttpRequest 4 | from django.http.response import HttpResponse 5 | 6 | from tenantapp.models import House 7 | 8 | 9 | def houses(request: HttpRequest) -> HttpResponse: 10 | # TODO: add nested table using FK 11 | # TODO: debug .prefetch_related('choice_set') 12 | return HttpResponse( 13 | json.dumps({ 14 | 'tenant': request.tenant.name, 15 | 'houses': [ 16 | { 17 | 'id': house.id, 18 | 'addr': house.addr, 19 | 'desc': house.desc, 20 | "created_at": house.created_at, 21 | "updated_at": house.updated_at, 22 | 23 | } for house in House.objects.all() 24 | ], 25 | }), 26 | content_type='application/json', 27 | ) 28 | -------------------------------------------------------------------------------- /testdjangoproject/tenants/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/tenants/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/tenants/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from tenants.models import Tenant 3 | 4 | 5 | class TenantAdmin(admin.ModelAdmin): 6 | list_display = ('pk', 'name', 'domain_name', 'database_name') 7 | list_display_links = ('pk', 'name') 8 | 9 | 10 | admin.site.register(Tenant, TenantAdmin) 11 | -------------------------------------------------------------------------------- /testdjangoproject/tenants/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TenantsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tenants" 7 | -------------------------------------------------------------------------------- /testdjangoproject/tenants/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-11-25 22:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Tenant", 16 | fields=[ 17 | ("id", models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | )), 23 | ("database_name", models.CharField(max_length=63, unique=True)), 24 | ("domain_name", models.CharField(max_length=253, unique=True)), 25 | ("name", models.CharField(max_length=100)), 26 | ], 27 | options={ 28 | "abstract": False, 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /testdjangoproject/tenants/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/tenants/migrations/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/tenants/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_multitenancy.mixins import TenantMixin 4 | 5 | 6 | class Tenant(TenantMixin): 7 | name = models.CharField(max_length=100, null=False) 8 | -------------------------------------------------------------------------------- /testdjangoproject/tenants/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from tenants.views import ( 4 | get_tanent_model_view, 5 | get_tenant_var_view, 6 | tenant_api__get_tenant_id, 7 | ) 8 | 9 | 10 | urlpatterns = [ 11 | path("tenant/model", get_tanent_model_view, name="tenant-model-view"), 12 | path("tenant/var", get_tenant_var_view, name="tenant-var-view"), 13 | path("tenant/get-id", tenant_api__get_tenant_id, name="tenant-get-id"), 14 | ] 15 | -------------------------------------------------------------------------------- /testdjangoproject/tenants/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse, JsonResponse 2 | from django_multitenancy.helpers import get_tenant_model, TENANT_VAR 3 | 4 | 5 | def get_tanent_model_view(request): 6 | model = get_tenant_model() 7 | html = f"

{model.__name__}

{request}

" 8 | return HttpResponse(html, content_type="text/html", status=200) 9 | 10 | 11 | def get_tenant_var_view(request): 12 | html = f"tenant: {TENANT_VAR.get()}" 13 | return HttpResponse(html, content_type="text/html", status=200) 14 | 15 | 16 | def tenant_api__get_tenant_id(request): 17 | tenant = TENANT_VAR.get() 18 | return JsonResponse({ 19 | "tenant_id": tenant.id, 20 | }) 21 | -------------------------------------------------------------------------------- /testdjangoproject/testdjangoproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/testdjangoproject/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/testdjangoproject/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for testdjangoproject project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjangoproject.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /testdjangoproject/testdjangoproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testdjangoproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-(teo+*8xvj=d*&vz6db8gzk=r)i4b9)z1p@2hkrc_k=62*9k@%" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [ 29 | '*', 30 | ] 31 | 32 | TENANT_MODEL = "tenants.Tenant" 33 | 34 | # data for this list of apps would exist in every database schema/database instance 35 | SHARED_APPS = [ 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | "tenants", 43 | ] 44 | 45 | # data for this list of apps would exist in single database schema/database instance 46 | TENANT_APPS = [ 47 | "tenantapp", 48 | ] 49 | 50 | # Application definition 51 | INSTALLED_APPS = SHARED_APPS + [app for app in TENANT_APPS if app not in SHARED_APPS] 52 | 53 | MIDDLEWARE = [ 54 | "django_multitenancy.middleware.MultiTenantMiddleware", 55 | "django.middleware.security.SecurityMiddleware", 56 | "django.contrib.sessions.middleware.SessionMiddleware", 57 | "django.middleware.common.CommonMiddleware", 58 | "django.middleware.csrf.CsrfViewMiddleware", 59 | "django.contrib.auth.middleware.AuthenticationMiddleware", 60 | "django.contrib.messages.middleware.MessageMiddleware", 61 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 62 | ] 63 | 64 | ROOT_URLCONF = "testdjangoproject.urls" 65 | 66 | TEMPLATES = [ 67 | { 68 | "BACKEND": "django.template.backends.django.DjangoTemplates", 69 | "DIRS": [], 70 | "APP_DIRS": True, 71 | "OPTIONS": { 72 | "context_processors": [ 73 | "django.template.context_processors.debug", 74 | "django.template.context_processors.request", 75 | "django.contrib.auth.context_processors.auth", 76 | "django.contrib.messages.context_processors.messages", 77 | ], 78 | }, 79 | }, 80 | ] 81 | 82 | WSGI_APPLICATION = "testdjangoproject.wsgi.application" 83 | 84 | 85 | # Database 86 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 87 | 88 | DATABASES = { 89 | "default": { 90 | 'ENGINE': 'django.db.backends.sqlite3', 91 | 'NAME': BASE_DIR / 'db.sqlite3', 92 | } 93 | } 94 | 95 | 96 | # Password validation 97 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 98 | 99 | AUTH_PASSWORD_VALIDATORS = [ 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 105 | }, 106 | { 107 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 108 | }, 109 | { 110 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 111 | }, 112 | ] 113 | 114 | 115 | # Internationalization 116 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 117 | 118 | LANGUAGE_CODE = 'en-us' 119 | 120 | TIME_ZONE = 'UTC' 121 | 122 | USE_I18N = True 123 | 124 | USE_TZ = True 125 | 126 | 127 | # Static files (CSS, JavaScript, Images) 128 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 129 | 130 | STATIC_URL = 'static/' 131 | 132 | # Default primary key field type 133 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 134 | 135 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 136 | -------------------------------------------------------------------------------- /testdjangoproject/testdjangoproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | 5 | urlpatterns = [ 6 | path("admin/", admin.site.urls), 7 | path("", include("tenants.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /testdjangoproject/testdjangoproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testdjangoproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjangoproject.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /testdjangoproject/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/tests/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/tests/tenants/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/testdjangoproject/tests/tenants/__init__.py -------------------------------------------------------------------------------- /testdjangoproject/tests/tenants/test_models.py: -------------------------------------------------------------------------------- 1 | from tenants.models import Tenant 2 | 3 | 4 | class TestTenant: 5 | 6 | def test_init(self, db): 7 | tenant = Tenant.objects.create( 8 | name="New tenant", 9 | database_name="tenant", 10 | domain_name="tenant.com", 11 | ) 12 | 13 | created_tenant = Tenant.objects.get(id=tenant.id) 14 | assert created_tenant.name == "New tenant" 15 | assert created_tenant.database_name == "tenant" 16 | assert created_tenant.domain_name == "tenant.com" 17 | -------------------------------------------------------------------------------- /testdjangoproject/tests/tenants/test_views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from tenants.models import Tenant 4 | 5 | 6 | def test_tenant_view__OK(client, db): 7 | tenant1 = Tenant.objects.create( 8 | name="tenant1", 9 | domain_name="tenant1.com", 10 | database_name="tenant1" 11 | ) 12 | Tenant.objects.create( 13 | name="tenant2", 14 | domain_name="tenant2.com", 15 | database_name="tenant2" 16 | ) 17 | 18 | response = client.get( 19 | reverse("tenant-get-id"), 20 | headers={ 21 | "Host": "tenant1.com", 22 | }, 23 | ) 24 | 25 | assert response.json() == {"tenant_id": tenant1.id} 26 | 27 | 28 | def test_tenant_view__OK_with_www_prefix(client, db): 29 | tenant1 = Tenant.objects.create( 30 | name="tenant1", 31 | domain_name="tenant1.com", 32 | database_name="tenant1" 33 | ) 34 | Tenant.objects.create( 35 | name="tenant2", 36 | domain_name="tenant2.com", 37 | database_name="tenant2" 38 | ) 39 | 40 | response = client.get( 41 | reverse("tenant-get-id"), 42 | headers={ 43 | "Host": "www.tenant1.com", 44 | }, 45 | ) 46 | 47 | assert response.json() == {"tenant_id": tenant1.id} 48 | 49 | 50 | def test_tenant_view__tenant_does_not_exist_404(client, db): 51 | Tenant.objects.create( 52 | name="tenant2", 53 | domain_name="tenant2.com", 54 | database_name="tenant2" 55 | ) 56 | response = client.get( 57 | reverse("tenant-get-id"), 58 | headers={ 59 | "Host": "tenant1.com", 60 | }, 61 | ) 62 | 63 | assert response.status_code == 404 64 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bp72/django-multitenancy/0e548991efc935e72653e2e7e07c74d5f9f05686/tests/helpers/__init__.py -------------------------------------------------------------------------------- /tests/helpers/test_get_tenant.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | import pytest 3 | from django.db import models 4 | from django_multitenancy.helpers import get_tenant 5 | 6 | 7 | def test_get_tenant__doesnt_exist__expect_error(mocker): 8 | tenant_model_mock = MagicMock() 9 | tenant_model_mock.objects.get.side_effect = [Exception('test.com')] 10 | mocker.patch("django_multitenancy.helpers.get_tenant_model", return_value=tenant_model_mock) 11 | 12 | with pytest.raises(Exception) as e: 13 | get_tenant(domain_name='test.com') 14 | 15 | 16 | def test_get_tenant(mocker): 17 | tenant_mock = MagicMock() 18 | tenant_model_mock = MagicMock() 19 | tenant_model_mock.objects.get.return_value = tenant_mock 20 | mocker.patch("django_multitenancy.helpers.get_tenant_model", return_value=tenant_model_mock) 21 | 22 | assert get_tenant(domain_name='test') is tenant_mock 23 | -------------------------------------------------------------------------------- /tests/middleware/test_base.py: -------------------------------------------------------------------------------- 1 | from django.test.client import RequestFactory 2 | import pytest 3 | from django.db import models 4 | from django_multitenancy.helpers import get_tenant 5 | from django_multitenancy.middleware import MultiTenantMiddleware 6 | from unittest.mock import MagicMock 7 | 8 | 9 | def test_middleware__host_no_www_prefix(rf: RequestFactory): 10 | get_response = MagicMock() 11 | request = rf.get('/', headers={"host": "example.com"}) 12 | middleware = MultiTenantMiddleware(get_response) 13 | host = middleware.get_hostname(request=request) 14 | assert host == "example.com" 15 | 16 | def test_middleware__host_with_www_prefix(rf: RequestFactory): 17 | get_response = MagicMock() 18 | request = rf.get('/', headers={"host": "www.example.com"}) 19 | middleware = MultiTenantMiddleware(get_response) 20 | host = middleware.get_hostname(request=request) 21 | assert host == "example.com" 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_middleware__tenant_does_not_exist(rf: RequestFactory): 26 | get_response = MagicMock() 27 | request = rf.get( 28 | '/', 29 | headers={"host": "example.com"}, 30 | ) 31 | 32 | with pytest.raises(Exception) as e: 33 | middleware = MultiTenantMiddleware(get_response) 34 | response = middleware(request) 35 | assert response.status_code == 404 36 | 37 | 38 | @pytest.mark.django_db 39 | def test_middleware__tenant_ok__WIP(rf: RequestFactory, mocker): 40 | get_response = MagicMock() 41 | response = MagicMock() 42 | response.json.return_value = {"tenant_id": 123} 43 | response.status_code = 200 44 | 45 | get_response.return_value = response 46 | 47 | request = rf.get('/', headers={"host": "example.com"}) 48 | 49 | tenant_mock = MagicMock() 50 | tenant_mock.id.return_value = 123 51 | tenant_mock.name.return_value = "example" 52 | tenant_mock.domain_name.return_value = "example.com" 53 | 54 | tenant_model_mock = MagicMock() 55 | tenant_model_mock.objects.get.return_value = tenant_mock 56 | 57 | mocker.patch("django_multitenancy.helpers.get_tenant_model", return_value=tenant_model_mock) 58 | 59 | middleware = MultiTenantMiddleware(get_response) 60 | resp = middleware(request) 61 | assert resp is response 62 | assert resp.json() == {"tenant_id": 123} 63 | assert resp.status_code == 200 64 | -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | def test_example(): 2 | assert True 3 | 4 | def test_get_tenant_model(): 5 | assert True -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 2 | [tox] 3 | envlist = py38,py39,py310,py311,py312,flake8 4 | 5 | [testenv] 6 | deps = -r{toxinidir}/requirements.txt 7 | commands = pytest --ds=testdjangoproject.settings testdjangoproject/tests tests -vv 8 | 9 | [testenv:flake8] 10 | skip_install = true 11 | deps = flake8 12 | commands = flake8 --max-line-length=100 django_multitenancy setup.py testdjangoproject 13 | --------------------------------------------------------------------------------