├── .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 | 
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 |
--------------------------------------------------------------------------------