├── django_multitenant ├── db │ ├── __init__.py │ └── migrations │ │ ├── __init__.py │ │ └── distribute.py ├── tests │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0022_merge_20200211_1000.py │ │ ├── 0031_alter_transaction_date.py │ │ ├── 0008_auto_20190412_0831.py │ │ ├── 0005_auto_20190301_0842.py │ │ ├── 0013_auto_20190517_1607.py │ │ ├── 0007_alter_foreignkey_field.py │ │ ├── 0009_auto_20190412_0839.py │ │ ├── 0006_subtask_project.py │ │ ├── 0021_auto_20200129_0453.py │ │ ├── 0019_auto_20200129_0357.py │ │ ├── 0020_auto_20200129_0404.py │ │ ├── 0017_auto_20200204_0929.py │ │ ├── 0016_auto_20191025_0844.py │ │ ├── 0015_auto_20190829_1334.py │ │ ├── 0004_auto_20190301_0834.py │ │ ├── 0011_distribute_new_table.py │ │ ├── 0014_auto_20190828_1314.py │ │ ├── 0030_store_user_alter_account_id_alter_aliasedtask_id_and_more.py │ │ ├── 0012_auto_20190517_1606.py │ │ ├── 0010_auto_20190517_1514.py │ │ ├── 0029_migration_tests_apps_get_model_20230318_0300.py │ │ ├── 0027_many_to_many_distribute.py │ │ ├── 0003_auto_20181220_1151.py │ │ ├── 0017_auto_20200128_0853.py │ │ ├── 0023_auto_20200412_0603.py │ │ ├── 0018_auto_20200128_0902.py │ │ ├── 0025_data_load.py │ │ ├── 0024_business_tenant_alter_account_id_and_more.py │ │ ├── 0002_distribute.py │ │ ├── 0028_migrationuseinmigrationsmodel_alter_account_managers_and_more.py │ │ ├── 0026_product_purchase_store_alter_account_id_and_more.py │ │ └── 0001_initial.py │ ├── serializers.py │ ├── urls.py │ ├── test_missing_modules.py │ ├── views.py │ ├── utils.py │ ├── test_viewsets.py │ ├── settings.py │ ├── test_migrations.py │ ├── base.py │ ├── test_utils.py │ └── models.py ├── backends │ ├── __init__.py │ └── postgresql │ │ ├── __init__.py │ │ └── base.py ├── django_multitenant.py ├── exceptions.py ├── apps.py ├── django_mt_environment.py ├── __init__.py ├── settings.py ├── middlewares.py ├── models.py ├── views.py ├── deletion.py ├── query.py ├── fields.py ├── utils.py └── mixins.py ├── requirements ├── release.in ├── static-analysis.in ├── test.in ├── test-requirements.txt ├── release-requirements.txt └── static-analysis-requirements.txt ├── .envs ├── docs ├── requirements.in ├── source │ ├── _static │ │ └── .gitignore │ ├── _templates │ │ └── .gitignore │ ├── general.rst │ ├── license.rst │ ├── conf.py │ ├── django_rest_integration.rst │ ├── index.rst │ └── usage.rst ├── Makefile └── requirements.txt ├── pytest.ini ├── .gitignore ├── scripts ├── test-django-mt.sh └── test-django-mt-modules.sh ├── .vscode └── settings.json ├── .readthedocs.yaml ├── .prospector.yaml ├── manage.py ├── LICENSE ├── .github └── workflows │ ├── django-base-image.yml │ └── django-multitenant-tests.yml ├── base_gha_image └── Dockerfile ├── docker-compose.yml ├── setup.py ├── CONTRIBUTING.md ├── Makefile ├── CHANGELOG.md └── README.md /django_multitenant/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_multitenant/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_multitenant/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_multitenant/django_multitenant.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/release.in: -------------------------------------------------------------------------------- 1 | twine 2 | build 3 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_multitenant/backends/postgresql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.envs: -------------------------------------------------------------------------------- 1 | DJANGO_SETTINGS_MODULE=django_multitenant.tests.settings 2 | -------------------------------------------------------------------------------- /django_multitenant/db/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | from .distribute import * 2 | -------------------------------------------------------------------------------- /django_multitenant/exceptions.py: -------------------------------------------------------------------------------- 1 | class EmptyTenant(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | sphinxnotes-strike 2 | sphinx 3 | sphinx_rtd_theme 4 | readthedocs-sphinx-search -------------------------------------------------------------------------------- /docs/source/_static/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --reuse-db 3 | filterwarnings = 4 | ignore::pytest.PytestCacheWarning 5 | 6 | -------------------------------------------------------------------------------- /docs/source/_templates/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .env/* 4 | django_multitenant.egg-info/ 5 | .venv 6 | .tox/ 7 | venv/* 8 | coverage.xml 9 | .coverage 10 | build/* 11 | dist/* -------------------------------------------------------------------------------- /scripts/test-django-mt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pyenv global "${PYTHON_VERSION}" 3 | echo "Python version: $(python --version)" 4 | cd /build || exit 5 | make test-dependencies 6 | make test 7 | -------------------------------------------------------------------------------- /django_multitenant/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.apps import AppConfig 3 | 4 | 5 | class MultitenantConfig(AppConfig): 6 | name = "django_multitenant" 7 | verbose_name = "Multitenant" 8 | -------------------------------------------------------------------------------- /scripts/test-django-mt-modules.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pyenv global "${PYTHON_VERSION}" 3 | echo "Python version: $(python --version)" 4 | cd /build || exit 5 | make test-dependencies 6 | 7 | make test-missing-modules 8 | -------------------------------------------------------------------------------- /requirements/static-analysis.in: -------------------------------------------------------------------------------- 1 | Django>=3.2.9 2 | black 3 | psycopg2-binary 4 | exam 5 | pytest 6 | pytest-cov 7 | pytest-django 8 | prospector[with_everything] 9 | pylint < 2.16 10 | Sphinx 11 | sphinxnotes.strike 12 | sphinx_rtd_theme 13 | djangorestframework 14 | -------------------------------------------------------------------------------- /requirements/test.in: -------------------------------------------------------------------------------- 1 | coverage[toml]==7.2.7 2 | pluggy==1.2.0 3 | psycopg2-binary 4 | pytest 5 | pytest-cov 6 | pytest-django 7 | exam 8 | asgiref>= 3.5.2 9 | typing-extensions==4.8.0; python_version >= "3.8" 10 | typing-extensions==4.7.1; python_version < "3.8" 11 | 12 | -------------------------------------------------------------------------------- /django_multitenant/tests/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from .models import Store 3 | 4 | 5 | class StoreSerializer(serializers.HyperlinkedModelSerializer): 6 | class Meta: 7 | model = Store 8 | fields = ["name", "address", "email"] 9 | -------------------------------------------------------------------------------- /django_multitenant/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework import routers 3 | from .views import StoreViewSet 4 | 5 | router = routers.DefaultRouter() 6 | router.register(r"store", StoreViewSet, basename="Store") 7 | 8 | urlpatterns = [ 9 | path("", include(router.urls)), 10 | ] 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestEnabled": false, 3 | "python.testing.pytestEnabled": true, 4 | "python.envFile": "${workspaceFolder}/.envs", 5 | "python.linting.prospectorEnabled": true, 6 | "python.linting.enabled": true, 7 | "python.languageServer": "Pylance", 8 | "esbonio.sphinx.confDir": "" 9 | } 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.11" 7 | 8 | # Build from the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | # Explicitly set the version of Python and its requirements 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /django_multitenant/django_mt_environment.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | 4 | def import_drf_or_die(): 5 | try: 6 | importlib.import_module("rest_framework") 7 | except ImportError as e: 8 | raise ImportError( 9 | "Django Multitenant requires Django Rest Framework to be installed." 10 | ) from e 11 | 12 | 13 | import_drf_or_die() 14 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0022_merge_20200211_1000.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-02-11 10:00 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tests", "0017_auto_20200204_0929"), 9 | ("tests", "0021_auto_20200129_0453"), 10 | ] 11 | 12 | operations = [] 13 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | test-warnings: true 2 | doc-warnings: false 3 | member-warnings: false 4 | 5 | uses: 6 | -django 7 | 8 | pylint: 9 | disable: 10 | - import-outside-toplevel 11 | - unused-variable 12 | - not-an-iterable 13 | - unsubscriptable-object 14 | options: 15 | max-args: 6 16 | django-settings-module: django_multitenant.settings 17 | 18 | pyflakes: 19 | disable: 20 | - F401 21 | - F841 22 | 23 | dodgy: 24 | run: false 25 | -------------------------------------------------------------------------------- /django_multitenant/tests/test_missing_modules.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | import pytest 3 | import importlib 4 | 5 | 6 | class ModuleImportTestCases(TransactionTestCase): 7 | def test_missing_drf(self): 8 | with pytest.raises( 9 | ImportError, 10 | match="Django Multitenant requires Django Rest Framework to be installed.", 11 | ): 12 | importlib.import_module("django_multitenant.views") 13 | -------------------------------------------------------------------------------- /django_multitenant/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import django 3 | 4 | version = (2, 3, 2) 5 | 6 | __version__ = ".".join(map(str, version)) 7 | 8 | # default_app_config is auto detected for versions 3.2 and higher: 9 | # https://docs.djangoproject.com/en/3.2/ref/applications/#for-application-authors 10 | if django.VERSION < (3, 2): 11 | default_app_config = "django_multitenant.apps.MultitenantConfig" 12 | 13 | __all__ = ["default_app_config", "version"] 14 | -------------------------------------------------------------------------------- /django_multitenant/tests/views.py: -------------------------------------------------------------------------------- 1 | from django_multitenant.views import TenantModelViewSet 2 | from .models import Store 3 | from .serializers import StoreSerializer 4 | from rest_framework import permissions 5 | 6 | 7 | class StoreViewSet(TenantModelViewSet): 8 | """ 9 | API endpoint that allows groups to be viewed or edited. 10 | """ 11 | 12 | model_class = Store 13 | serializer_class = StoreSerializer 14 | permission_classes = [permissions.IsAuthenticated] 15 | -------------------------------------------------------------------------------- /django_multitenant/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | APP_NAMESPACE = "DJANGO_MULTITENANT" 4 | 5 | TENANT_COOKIE_NAME = getattr(settings, "TENANT_COOKIE_NAME", "tenant_id") 6 | TENANT_MODEL_NAME = getattr(settings, "TENANT_MODEL_NAME", None) 7 | CITUS_EXTENSION_INSTALLED = getattr(settings, "CITUS_EXTENSION_INSTALLED", False) 8 | TENANT_STRICT_MODE = getattr(settings, "TENANT_STRICT_MODE", False) 9 | TENANT_USE_ASGIREF = getattr(settings, "TENANT_USE_ASGIREF", False) 10 | -------------------------------------------------------------------------------- /django_multitenant/middlewares.py: -------------------------------------------------------------------------------- 1 | from django_multitenant.utils import set_current_tenant 2 | 3 | from django_multitenant.views import get_tenant 4 | 5 | 6 | class MultitenantMiddleware: 7 | def __init__(self, get_response): 8 | self.get_response = get_response 9 | 10 | def __call__(self, request): 11 | if request.user and not request.user.is_anonymous: 12 | tenant = get_tenant(request) 13 | set_current_tenant(tenant) 14 | return self.get_response(request) 15 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0031_alter_transaction_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-04 16:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tests", "0030_store_user_alter_account_id_alter_aliasedtask_id_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="transaction", 14 | name="date", 15 | field=models.DateField(auto_now_add=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /docs/source/general.rst: -------------------------------------------------------------------------------- 1 | .. _general: 2 | 3 | Installation 4 | ================================= 5 | 6 | 1. ``pip install --no-cache-dir django_multitenant`` 7 | 8 | Supported Django and Citus versions/Pre-requisites 9 | =================================================== 10 | 11 | ================= ====== ========= 12 | Python Django Citus 13 | ================= ====== ========= 14 | 3.8 3.9 3.10 3.11 4.2 11 12 15 | 3.8 3.9 3.10 3.11 4.1 11 12 16 | 3.8 3.9 3.10 3.11 4.0 10 11 12 17 | 3.7 3.2 10 11 12 18 | ================= ====== ========= 19 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0008_auto_20190412_0831.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2019-04-12 08:31 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0007_alter_foreignkey_field"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="somerelatedmodel", 16 | name="opened", 17 | field=models.BooleanField(default=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | SUPPORTED_ENVS = "tests" 7 | 8 | SETTINGS_MODULES = { 9 | "test": "django_multitenant.tests.settings", 10 | } 11 | 12 | ENV = os.environ.get("ENV", "test") 13 | ENV = ENV.lower() 14 | 15 | if ENV not in SUPPORTED_ENVS: 16 | raise EnvironmentError(f"Unsupported environment: {ENV}") 17 | 18 | if __name__ == "__main__": 19 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", SETTINGS_MODULES[ENV]) 20 | from django.core.management import execute_from_command_line 21 | 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0005_auto_20190301_0842.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2019-03-01 08:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tests", "0004_auto_20190301_0834"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="tenantnotidmodel", 14 | name="tenant_column", 15 | field=models.IntegerField( 16 | editable=False, primary_key=True, serialize=False 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0013_auto_20190517_1607.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2019-05-17 16:07 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0012_auto_20190517_1606"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="tempmodel", 16 | name="project", 17 | ), 18 | migrations.DeleteModel( 19 | name="TempModel", 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /django_multitenant/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import models 4 | 5 | from .mixins import TenantManagerMixin, TenantModelMixin 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class TenantManager(TenantManagerMixin, models.Manager): 12 | # This parameter is required to add Tenenat Manager in models in the migrations. 13 | use_in_migrations = True 14 | 15 | 16 | class TenantModel(TenantModelMixin, models.Model): 17 | # Abstract model which all the models related to tenant inherit. 18 | 19 | objects = TenantManager() 20 | 21 | class Meta: 22 | abstract = True 23 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0007_alter_foreignkey_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2019-03-01 08:45 2 | 3 | from django.db import migrations 4 | import django.db.models.deletion 5 | import django_multitenant.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0006_subtask_project"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="task", 16 | name="project", 17 | field=django_multitenant.fields.TenantForeignKey( 18 | on_delete=django.db.models.deletion.CASCADE, to="tests.Project" 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0009_auto_20190412_0839.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2019-04-12 08:39 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0008_auto_20190412_0831"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="somerelatedmodel", 16 | name="opened", 17 | ), 18 | migrations.AddField( 19 | model_name="task", 20 | name="opened", 21 | field=models.BooleanField(default=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0006_subtask_project.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2019-03-01 08:45 2 | 3 | from django.db import migrations 4 | import django.db.models.deletion 5 | import django_multitenant.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0005_auto_20190301_0842"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="subtask", 16 | name="project", 17 | field=django_multitenant.fields.TenantForeignKey( 18 | null=True, 19 | on_delete=django.db.models.deletion.CASCADE, 20 | to="tests.Project", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /django_multitenant/views.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-import 2 | import django_multitenant.django_mt_environment 3 | from django_multitenant.utils import set_current_tenant 4 | from django_multitenant.models import TenantModel 5 | from rest_framework import viewsets 6 | from abc import abstractmethod 7 | 8 | 9 | @abstractmethod 10 | def get_tenant(request) -> TenantModel: 11 | pass 12 | 13 | 14 | class TenantModelViewSet(viewsets.ModelViewSet): 15 | model_class = TenantModel 16 | 17 | def get_queryset(self): 18 | if self.request.user.is_anonymous: 19 | return self.model_class.objects.none() 20 | tenant = get_tenant(self.request) 21 | set_current_tenant(tenant) 22 | list3 = self.model_class.objects.all() 23 | return list3 24 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0021_auto_20200129_0453.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-29 04:53 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tests", "0020_auto_20200129_0404"), 10 | ] 11 | atomic = False 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="modelconfig", 15 | name="employee", 16 | field=models.ForeignKey( 17 | blank=True, 18 | null=True, 19 | on_delete=django.db.models.deletion.CASCADE, 20 | related_name="configs", 21 | to="tests.Employee", 22 | ), 23 | ) 24 | ] 25 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0019_auto_20200129_0357.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-29 03:57 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tests", "0018_auto_20200128_0902"), 10 | ] 11 | 12 | atomic = False 13 | operations = [ 14 | migrations.AddField( 15 | model_name="account", 16 | name="employee", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="accounts", 22 | to="tests.Employee", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0020_auto_20200129_0404.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-29 04:04 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tests", "0019_auto_20200129_0357"), 10 | ] 11 | 12 | atomic = False 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="project", 17 | name="employee", 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | related_name="projects", 23 | to="tests.Employee", 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0017_auto_20200204_0929.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-02-04 09:29 2 | 3 | from django.db import migrations 4 | import django.db.models.deletion 5 | import django_multitenant.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0016_auto_20191025_0844"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="task", 16 | name="parent", 17 | field=django_multitenant.fields.TenantForeignKey( 18 | blank=True, 19 | db_index=False, 20 | null=True, 21 | on_delete=django.db.models.deletion.CASCADE, 22 | to="tests.Task", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0016_auto_20191025_0844.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.23 on 2019-10-25 08:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | import django.db.models.deletion 7 | import django_multitenant.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("tests", "0015_auto_20190829_1334"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="projectmanager", 18 | name="project", 19 | field=django_multitenant.fields.TenantForeignKey( 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="projectmanagers", 22 | to="tests.Project", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0015_auto_20190829_1334.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.22 on 2019-08-29 13:34 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tests", "0014_auto_20190828_1314"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="MigrationTestReferenceModel", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0004_auto_20190301_0834.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2019-03-01 08:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tests", "0003_auto_20181220_1151"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="manager", 15 | name="account", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | related_name="managers", 19 | to="tests.Account", 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="project", 24 | name="account", 25 | field=models.ForeignKey( 26 | on_delete=django.db.models.deletion.CASCADE, 27 | related_name="projects", 28 | to="tests.Account", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Citus Data Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to use, 6 | copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 7 | Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0011_distribute_new_table.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2019-05-17 15:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | from django.conf import settings 8 | 9 | from django_multitenant.db import migrations as tenant_migrations 10 | 11 | 12 | def get_operations(): 13 | operations = [ 14 | migrations.RunSQL( 15 | "ALTER TABLE tests_tempmodel DROP CONSTRAINT tests_tempmodel_pkey CASCADE;", 16 | reverse_sql="", 17 | ), 18 | migrations.RunSQL( 19 | "ALTER TABLE tests_tempmodel ADD CONSTRAINT tests_tempmodel_pkey PRIMARY KEY (account_id, id);", 20 | reverse_sql="", 21 | ), 22 | ] 23 | 24 | if settings.USE_CITUS: 25 | operations += [ 26 | tenant_migrations.Distribute("TempModel", reverse_ignore=True), 27 | ] 28 | 29 | return operations 30 | 31 | 32 | class Migration(migrations.Migration): 33 | dependencies = [ 34 | ("tests", "0010_auto_20190517_1514"), 35 | ] 36 | operations = get_operations() 37 | -------------------------------------------------------------------------------- /.github/workflows/django-base-image.yml: -------------------------------------------------------------------------------- 1 | name: "Django MT Docker Image For Tests" 2 | on: 3 | schedule: 4 | - cron: 30 0 * * * 5 | push: 6 | branches: 7 | - "python-all-versions/**" 8 | - "main" 9 | workflow_dispatch: 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | name: Build Django Base Image 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Login to DockerHub 20 | uses: docker/login-action@v2 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USER_NAME }} 23 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 24 | - name: Build and push 25 | env: 26 | DOCKER_BUILDKIT: 1 27 | run: | 28 | set -euxo pipefail 29 | docker build -t citusdata/python-all-versions base_gha_image 30 | if [[ ${GITHUB_REF##*/} == "main" ]]; then 31 | docker push citusdata/python-all-versions 32 | else 33 | docker tag citusdata/python-all-versions citusdata/python-all-versions:${GITHUB_REF##*/} 34 | docker push citusdata/python-all-versions:${GITHUB_REF##*/} 35 | fi 36 | 37 | 38 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0014_auto_20190828_1314.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.22 on 2019-08-28 13:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django_multitenant.fields 7 | import django_multitenant.mixins 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("tests", "0013_auto_20190517_1607"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="MigrationTestModel", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("name", models.CharField(max_length=255)), 29 | ], 30 | options={ 31 | "abstract": False, 32 | }, 33 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /docs/source/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | =============================================== 3 | Copyright (c) 2023 , Citus Data Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to use, 8 | copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 9 | Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # For the full list of built-in configuration values, see the documentation: 6 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 7 | 8 | # -- Project information ----------------------------------------------------- 9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 10 | 11 | project = "Django Multi-tenant" 12 | copyright = f"{date.today().year} .Citus Data Licensed under the MIT license, see License for details. " 13 | author = "Citus Data" 14 | release = "3.0.0" 15 | 16 | # -- General configuration --------------------------------------------------- 17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 18 | 19 | extensions = ["sphinxnotes.strike"] 20 | 21 | templates_path = ["_templates"] 22 | exclude_patterns = [] 23 | 24 | language = "python" 25 | 26 | # -- Options for HTML output ------------------------------------------------- 27 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 28 | 29 | html_theme = "sphinx_rtd_theme" 30 | html_static_path = ["_static"] 31 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0030_store_user_alter_account_id_alter_aliasedtask_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2023-03-06 10:22 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("tests", "0029_migration_tests_apps_get_model_20230318_0300"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RunSQL( 16 | "ALTER TABLE auth_user ALTER COLUMN id SET DATA TYPE bigint;" 17 | ), 18 | migrations.RunSQL( 19 | "ALTER TABLE auth_group ALTER COLUMN id SET DATA TYPE bigint;" 20 | ), 21 | migrations.RunSQL("SET LOCAL citus.multi_shard_modify_mode TO 'sequential';"), 22 | migrations.RunSQL("SELECT create_reference_table('auth_user');"), 23 | migrations.AddField( 24 | model_name="store", 25 | name="user", 26 | field=models.ForeignKey( 27 | null=True, 28 | on_delete=django.db.models.deletion.CASCADE, 29 | to=settings.AUTH_USER_MODEL, 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /requirements/test-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/test-requirements.txt --resolver=backtracking requirements/test.in 6 | # 7 | asgiref==3.7.2 8 | # via -r requirements/test.in 9 | coverage[toml]==7.2.7 10 | # via 11 | # -r requirements/test.in 12 | # pytest-cov 13 | exam==0.10.6 14 | # via -r requirements/test.in 15 | exceptiongroup==1.1.3 16 | # via pytest 17 | iniconfig==2.0.0 18 | # via pytest 19 | mock==5.1.0 20 | # via exam 21 | packaging==23.1 22 | # via pytest 23 | pluggy==1.2.0 24 | # via 25 | # -r requirements/test.in 26 | # pytest 27 | psycopg2-binary==2.9.7 28 | # via -r requirements/test.in 29 | pytest==7.4.2 30 | # via 31 | # -r requirements/test.in 32 | # pytest-cov 33 | # pytest-django 34 | pytest-cov==4.1.0 35 | # via -r requirements/test.in 36 | pytest-django==4.5.2 37 | # via -r requirements/test.in 38 | tomli==2.0.1 39 | # via 40 | # coverage 41 | # pytest 42 | typing-extensions==4.8.0 ; python_version >= "3.8" 43 | # via 44 | # -r requirements/test.in 45 | # asgiref 46 | typing-extensions==4.7.1 ; python_version < "3.8" 47 | -------------------------------------------------------------------------------- /base_gha_image/Dockerfile: -------------------------------------------------------------------------------- 1 | # vim:set ft=dockerfile: 2 | FROM ubuntu:jammy 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | 5 | SHELL ["/bin/bash", "-ec"] 6 | 7 | RUN apt-get update && apt-get install -y \ 8 | apt-transport-https \ 9 | ca-certificates \ 10 | curl \ 11 | gnupg \ 12 | build-essential \ 13 | git \ 14 | zlib1g-dev \ 15 | lsb-release \ 16 | software-properties-common \ 17 | libbz2-dev \ 18 | libreadline-dev \ 19 | libsqlite3-dev \ 20 | libssl-dev \ 21 | libffi-dev \ 22 | liblzma-dev \ 23 | gdal-bin \ 24 | && rm -rf /var/lib/apt/lists/* 25 | 26 | # See https://github.com/tianon/docker-brew-debian/issues/49 for discussion of the following 27 | # 28 | # https://bugs.debian.org/830696 (apt uses gpgv by default in newer releases, rather than gpg) 29 | RUN curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash \ 30 | && echo 'export PATH="/root/.pyenv/bin:$PATH"' >> /root/.bashrc \ 31 | && echo 'eval "$(pyenv init -)"' >> /root/.bashrc 32 | 33 | 34 | ENV PATH="$PATH:/root/.pyenv/bin:/root/.pyenv/shims" 35 | ENV PYENV_ROOT="/root/.pyenv" 36 | 37 | RUN pyenv install 3.7 && pyenv install 3.8 && pyenv install 3.9 && pyenv install 3.10 && pyenv install 3.11 && pyenv global 3.11 38 | 39 | WORKDIR /root 40 | -------------------------------------------------------------------------------- /django_multitenant/tests/utils.py: -------------------------------------------------------------------------------- 1 | def undistribute_table(connection, table_name): 2 | queries = [ 3 | "CREATE TABLE %(table_name)s_bis (LIKE %(table_name)s INCLUDING ALL);" 4 | "CREATE TEMP TABLE %(table_name)s_temp AS SELECT * FROM %(table_name)s;" 5 | "INSERT INTO %(table_name)s_bis SELECT * FROM %(table_name)s_temp;" 6 | "DROP TABLE %(table_name)s CASCADE;" 7 | "ALTER TABLE %(table_name)s_bis RENAME TO %(table_name)s;" 8 | ] 9 | 10 | with connection.cursor() as cursor: 11 | for query in queries: 12 | cursor.execute(query % {"table_name": table_name}) 13 | 14 | 15 | def is_table_distributed(connection, table_name, column_name): 16 | query = """ 17 | SELECT logicalrelid, pg_attribute.attname 18 | FROM pg_dist_partition 19 | INNER JOIN pg_attribute ON (logicalrelid=attrelid) 20 | WHERE logicalrelid::varchar(255) = '{}' 21 | AND partmethod='h' 22 | AND attnum=substring(partkey from '%:varattno #"[0-9]+#"%' for '#')::int; 23 | """ 24 | distributed = False 25 | with connection.cursor() as cursor: 26 | cursor.execute(query.format(table_name)) 27 | row = cursor.fetchone() 28 | 29 | if row and row[1] == column_name: 30 | distributed = True 31 | 32 | return distributed 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | master: 5 | container_name: "${COMPOSE_PROJECT_NAME:-citus}_master" 6 | image: 'citusdata/citus:${CITUS_VERSION:-11.2}' 7 | ports: [ '5600:5432' ] 8 | labels: [ 'com.citusdata.role=Master' ] 9 | environment: 10 | - POSTGRES_HOST_AUTH_METHOD=trust 11 | command: -c fsync=off -c full_page_writes=off 12 | worker1: 13 | container_name: "${COMPOSE_PROJECT_NAME:-citus}_worker1_1" 14 | image: 'citusdata/citus:${CITUS_VERSION:-11.2}' 15 | ports: [ '5601:5432' ] 16 | labels: [ 'com.citusdata.role=Worker' ] 17 | depends_on: { manager: { condition: service_healthy } } 18 | environment: 19 | - POSTGRES_HOST_AUTH_METHOD=trust 20 | command: -c fsync=off -c full_page_writes=off 21 | worker2: 22 | container_name: "${COMPOSE_PROJECT_NAME:-citus}_worker2_1" 23 | image: 'citusdata/citus:${CITUS_VERSION:-11.2}' 24 | ports: [ '5602:5432' ] 25 | labels: [ 'com.citusdata.role=Worker' ] 26 | depends_on: { manager: { condition: service_healthy } } 27 | environment: 28 | - POSTGRES_HOST_AUTH_METHOD=trust 29 | command: -c fsync=off -c full_page_writes=off 30 | manager: 31 | container_name: "${COMPOSE_PROJECT_NAME:-citus}_manager" 32 | image: 'citusdata/membership-manager:0.2.0' 33 | volumes: [ '/var/run/docker.sock:/var/run/docker.sock' ] 34 | depends_on: { master: { condition: service_healthy } } 35 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0012_auto_20190517_1606.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2019-05-17 16:06 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django_multitenant.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("tests", "0011_distribute_new_table"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="projectmanager", 18 | name="project", 19 | field=django_multitenant.fields.TenantForeignKey( 20 | on_delete=django.db.models.deletion.CASCADE, to="tests.Project" 21 | ), 22 | ), 23 | migrations.AddField( 24 | model_name="tempmodel", 25 | name="project", 26 | field=django_multitenant.fields.TenantForeignKey( 27 | null=True, 28 | on_delete=django.db.models.deletion.CASCADE, 29 | to="tests.Project", 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="tempmodel", 34 | name="account", 35 | field=models.ForeignKey( 36 | on_delete=django.db.models.deletion.CASCADE, 37 | to="tests.Account", 38 | db_constraint=False, 39 | ), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /django_multitenant/deletion.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from functools import reduce 3 | 4 | import django 5 | from django.db.models import Q 6 | 7 | from .utils import get_current_tenant, get_tenant_filters 8 | 9 | 10 | def related_objects(obj, *args): 11 | """ 12 | Override of Collector.related_objects. Returns the filter for the related objects 13 | Different from the original method, this method adds the tenant filters to the query. 14 | CAUTION: When Collector.related_objects is changed, this method should be updated accordingly. 15 | """ 16 | if django.VERSION < (3, 0) or len(args) == 2: 17 | related = args[0] 18 | related_model = related.related_model 19 | related_fields = [related.field] 20 | objs = args[1] 21 | else: 22 | # Starting django 3.1 the signature of related_objects changed to 23 | # def related_objects(self, related_model, related_fields, objs) 24 | related_model = args[0] 25 | related_fields = args[1] 26 | objs = args[2] 27 | 28 | filters = {} 29 | predicate = reduce( 30 | operator.or_, 31 | (Q(**{f"{related_field.name}__in": objs}) for related_field in related_fields), 32 | ) 33 | 34 | if get_current_tenant(): 35 | try: 36 | filters = get_tenant_filters(related_model) 37 | except ValueError: 38 | pass 39 | # pylint: disable=protected-access 40 | return related_model._base_manager.using(obj.using).filter(predicate, **filters) 41 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0010_auto_20190517_1514.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2019-05-17 15:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django_multitenant.fields 8 | import django_multitenant.mixins 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ("tests", "0009_auto_20190412_0839"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="TempModel", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("name", models.CharField(max_length=255)), 30 | ( 31 | "account", 32 | models.ForeignKey( 33 | on_delete=django.db.models.deletion.CASCADE, 34 | to="tests.Account", 35 | db_constraint=False, 36 | ), 37 | ), 38 | ], 39 | options={ 40 | "abstract": False, 41 | }, 42 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0029_migration_tests_apps_get_model_20230318_0300.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-18 08:00 2 | 3 | from django.db import migrations 4 | 5 | from ..models import MigrationUseInMigrationsModel 6 | from django.apps import apps as apps_global 7 | 8 | 9 | def test_valid_model(model_class): 10 | assert model_class.objects.__class__.__name__ == "TenantManager" 11 | 12 | model_class.objects.create(name="test") 13 | 14 | 15 | def test_invalid_model(apps): 16 | model_from_apps = apps.get_model("tests", "MigrationUseInMigrationsModel") 17 | try: 18 | model_from_apps.objects.create(name="test") 19 | except AttributeError as e: 20 | assert str(e) == ( 21 | "apps.get_model method should not be used to get the model MigrationUseInMigrationsModel." 22 | "Either import the model directly or use the module apps under the module django.apps." 23 | ) 24 | 25 | 26 | # pylint: disable=unused-argument 27 | def empty_users(apps, schema_editor): 28 | model_from_global = apps_global.get_model("tests", "MigrationUseInMigrationsModel") 29 | test_valid_model(model_from_global) 30 | 31 | test_valid_model(MigrationUseInMigrationsModel) 32 | 33 | test_invalid_model(apps) 34 | 35 | 36 | class Migration(migrations.Migration): 37 | dependencies = [ 38 | ("tests", "0028_migrationuseinmigrationsmodel_alter_account_managers_and_more"), 39 | ] 40 | 41 | operations = [ 42 | migrations.RunPython(empty_users), 43 | ] 44 | -------------------------------------------------------------------------------- /django_multitenant/tests/test_viewsets.py: -------------------------------------------------------------------------------- 1 | from django_multitenant import views 2 | from .base import BaseTestCase 3 | 4 | 5 | from django.contrib.auth import get_user_model 6 | from rest_framework.test import APIClient 7 | from .models import Store 8 | from .serializers import StoreSerializer 9 | 10 | 11 | class ViewSetTestCases(BaseTestCase): 12 | def setUp(self): 13 | def tenant_func(request): 14 | return Store.objects.filter(user=request.user).first() 15 | 16 | views.get_tenant = tenant_func 17 | 18 | self.user = get_user_model().objects.create_user( 19 | username="testuser", email="testuser@example.com", password="testpass" 20 | ) 21 | 22 | self.user2 = get_user_model().objects.create_user( 23 | username="testuser2", email="testuser2@example.com", password="testpass2" 24 | ) 25 | self.client = APIClient() 26 | self.client.login(username="testuser", password="testpass") 27 | 28 | def test_list(self): 29 | # create some test objects 30 | 31 | store = Store.objects.create(name="store1", user=self.user) 32 | store.save() 33 | 34 | store2 = Store.objects.create(name="store2", user=self.user2) 35 | store2.save() 36 | 37 | # make the request and check the response if it is matching the store object 38 | # related to the test_user which is the logged in user 39 | 40 | response = self.client.get("/store/") 41 | expected_data = StoreSerializer([store], many=True).data 42 | self.assertEqual(response.status_code, 200) 43 | self.assertEqual(response.data, expected_data) 44 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=docs/requirements.txt --resolver=backtracking docs/requirements.in 6 | # 7 | alabaster==0.7.13 8 | # via sphinx 9 | babel==2.12.1 10 | # via sphinx 11 | certifi==2024.7.4 12 | # via requests 13 | charset-normalizer==3.2.0 14 | # via requests 15 | docutils==0.18.1 16 | # via 17 | # sphinx 18 | # sphinx-rtd-theme 19 | idna==3.7 20 | # via requests 21 | imagesize==1.4.1 22 | # via sphinx 23 | importlib-metadata==6.8.0 24 | # via sphinx 25 | jinja2==3.1.4 26 | # via sphinx 27 | markupsafe==2.1.3 28 | # via jinja2 29 | packaging==23.1 30 | # via sphinx 31 | pygments==2.16.1 32 | # via sphinx 33 | pytz==2023.3.post1 34 | # via babel 35 | readthedocs-sphinx-search==0.3.2 36 | # via -r docs/requirements.in 37 | requests==2.31.0 38 | # via sphinx 39 | snowballstemmer==2.2.0 40 | # via sphinx 41 | sphinx==7.1.2 42 | # via 43 | # -r docs/requirements.in 44 | # sphinx-rtd-theme 45 | # sphinxcontrib-jquery 46 | # sphinxnotes-strike 47 | sphinx-rtd-theme==1.3.0 48 | # via -r docs/requirements.in 49 | sphinxcontrib-applehelp==1.0.4 50 | # via sphinx 51 | sphinxcontrib-devhelp==1.0.2 52 | # via sphinx 53 | sphinxcontrib-htmlhelp==2.0.1 54 | # via sphinx 55 | sphinxcontrib-jquery==4.1 56 | # via sphinx-rtd-theme 57 | sphinxcontrib-jsmath==1.0.1 58 | # via sphinx 59 | sphinxcontrib-qthelp==1.0.3 60 | # via sphinx 61 | sphinxcontrib-serializinghtml==1.1.5 62 | # via sphinx 63 | sphinxnotes-strike==1.2 64 | # via -r docs/requirements.in 65 | urllib3==2.0.5 66 | # via requests 67 | zipp==3.17.0 68 | # via importlib-metadata 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os import path 3 | from io import open 4 | 5 | # read the contents of your README file 6 | this_directory = path.dirname(path.abspath(__file__)) 7 | 8 | with open(path.join(this_directory, "README.md")) as f: 9 | long_description = f.read() 10 | 11 | 12 | # Arguments marked as "Required" below must be included for upload to PyPI. 13 | # Fields marked as "Optional" may be commented out. 14 | 15 | setup( 16 | name="django-multitenant", 17 | version="4.1.1", # Required 18 | description="Django Library to Implement Multi-tenant databases", 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | url="https://github.com/citusdata/django-multitenant", 22 | author="Gurkan Indibay", 23 | author_email="gindibay@microsoft.com", 24 | # Classifiers help users find your project by categorizing it. 25 | # 26 | # For a list of valid classifiers, see https://pypi.org/classifiers/ 27 | classifiers=[ 28 | "Development Status :: 5 - Production/Stable ", 29 | "Topic :: Database", 30 | "License :: OSI Approved :: MIT License", 31 | "Intended Audience :: Developers", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3 :: Only", 34 | "Programming Language :: Python :: 3.7", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | ], 40 | keywords=("citus django multi tenant" "django postgres multi-tenant"), 41 | packages=find_packages( 42 | exclude=["*.tests", "*.tests.*", "tests.*", "tests", "docs", "docs.*"] 43 | ), 44 | ) 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions 4 | require you to agree to a Contributor License Agreement (CLA) declaring that 5 | you have the right to, and actually do, grant us the rights to use your 6 | contribution. For details, visit https://cla.microsoft.com. 7 | 8 | When you submit a pull request, a CLA-bot will automatically determine 9 | whether you need to provide a CLA and decorate the PR appropriately (e.g., 10 | label, comment). Simply follow the instructions provided by the bot. You 11 | will only need to do this once across all repositories using our CLA. 12 | 13 | This project has adopted the [Microsoft Open Source Code of 14 | Conduct](https://opensource.microsoft.com/codeofconduct/). For more 15 | information see the [Code of Conduct 16 | FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact 17 | [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional 18 | questions or comments. 19 | 20 | ### Following our coding conventions 21 | 22 | To format the python test files we use [black](https://github.com/psf/black). 23 | After installing like this you can run the following before committing: 24 | ```bash 25 | make format 26 | ``` 27 | 28 | You can also run the following to automatically format all the files that you 29 | have changed before committing. 30 | 31 | ```bash 32 | cat > .git/hooks/pre-commit << __EOF__ 33 | #!/bin/bash 34 | black --check --quiet . || { black .; exit 1; } 35 | __EOF__ 36 | chmod +x .git/hooks/pre-commit 37 | ``` 38 | 39 | ### Running tests 40 | 41 | In one shell start a docker compose citus cluster: 42 | ```bash 43 | docker-compose --project-name django-multitenant up -d || { docker-compose logs && false ; } 44 | ``` 45 | 46 | Then in another shell run the tests: 47 | 48 | ```bash 49 | export DJANGO_VERSION=4.1 50 | export CITUS_VERSION=11.2 51 | make test-dependencies 52 | make test 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/source/django_rest_integration.rst: -------------------------------------------------------------------------------- 1 | Using with Django Rest Framwork 2 | ================================= 3 | 4 | 1. Add ``'django_multitenant.middleware.MultitenantMiddleware'`` to the ``MIDDLEWARE`` list in your ``settings.py`` file: 5 | 6 | .. code-block:: python 7 | 8 | MIDDLEWARE = [ 9 | # other middleware 10 | 'django_multitenant.middleware.MultitenantMiddleware', 11 | ] 12 | 13 | 2. Monkey patch ``django_multitenant.views.get_tenant`` function with your own function which returns tenant object: 14 | 15 | .. code-block:: python 16 | 17 | # views.py 18 | 19 | def tenant_func(request): 20 | return Store.objects.filter(user=request.user).first() 21 | 22 | # Monkey patching get_tenant function 23 | from django_multitenant import views 24 | views.get_tenant = tenant_func 25 | 3. Add your viewset derived from TenantModelViewSet: 26 | 27 | .. code-block:: python 28 | 29 | # views.py 30 | 31 | class StoreViewSet(TenantModelViewSet): 32 | """ 33 | API endpoint that allows groups to be viewed or edited. 34 | """ 35 | model_class = Store 36 | serializer_class = StoreSerializer 37 | permission_classes = [permissions.IsAuthenticated] 38 | 39 | In the above example, we're defining a StoreViewSet that is derived from TenantModelViewSet. This means that any views in StoreViewSet will automatically be scoped to the current tenant based on the tenant attribute of the request object. 40 | 41 | You can then define the queryset and serializer_class attributes as usual. Note that you do not need to filter the queryset by the current tenant, as this is automatically handled by django-multitenant. 42 | 43 | That's it! With these steps, you should be able to use django-multitenant with Django Rest Framework to build multi-tenant applications. 44 | 45 | 46 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0027_many_to_many_distribute.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2023-02-23 17:24 2 | 3 | from django.db import migrations 4 | from django_multitenant.db import migrations as tenant_migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tests", "0026_product_purchase_store_alter_account_id_and_more"), 10 | ] 11 | operations = [] 12 | 13 | operations += [ 14 | # Drop constraints 15 | migrations.RunSQL( 16 | "ALTER TABLE tests_store DROP CONSTRAINT tests_store_pkey CASCADE;" 17 | ), 18 | migrations.RunSQL( 19 | "ALTER TABLE tests_product DROP CONSTRAINT tests_product_pkey CASCADE;" 20 | ), 21 | migrations.RunSQL( 22 | "ALTER TABLE tests_purchase DROP CONSTRAINT tests_purchase_pkey CASCADE;" 23 | ), 24 | migrations.RunSQL( 25 | "ALTER TABLE tests_transaction DROP CONSTRAINT tests_transaction_pkey CASCADE;" 26 | ), 27 | ] 28 | 29 | operations += [ 30 | tenant_migrations.Distribute("Staff", reference=True), 31 | tenant_migrations.Distribute("Store"), 32 | tenant_migrations.Distribute("Product"), 33 | tenant_migrations.Distribute("Purchase"), 34 | tenant_migrations.Distribute("Transaction"), 35 | ] 36 | 37 | operations += [ 38 | migrations.RunSQL( 39 | "ALTER TABLE tests_store ADD CONSTRAINT tests_store_pkey PRIMARY KEY (id);" 40 | ), 41 | migrations.RunSQL( 42 | "ALTER TABLE tests_product ADD CONSTRAINT tests_product_pkey PRIMARY KEY (store_id, id);" 43 | ), 44 | migrations.RunSQL( 45 | "ALTER TABLE tests_purchase ADD CONSTRAINT tests_purchase_pkey PRIMARY KEY (store_id, id);" 46 | ), 47 | migrations.RunSQL( 48 | "ALTER TABLE tests_transaction ADD CONSTRAINT tests_transaction_pkey PRIMARY KEY (store_id, id);" 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0003_auto_20181220_1151.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-12-20 11:51 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.conf import settings 7 | 8 | from django_multitenant.db import migrations as tenant_migrations 9 | 10 | 11 | def get_operations(): 12 | operations = [ 13 | migrations.RunSQL( 14 | "ALTER TABLE tests_tenantnotidmodel DROP CONSTRAINT tests_tenantnotidmodel_pkey CASCADE;", 15 | reverse_sql="ALTER TABLE tests_tenantnotidmodel ADD CONSTRAINT tests_tenantnotidmodel_pkey PRIMARY KEY (tenant_column);", 16 | ), 17 | migrations.RunSQL( 18 | "ALTER TABLE tests_somerelatedmodel DROP CONSTRAINT tests_somerelatedmodel_pkey CASCADE;", 19 | reverse_sql="ALTER TABLE tests_somerelatedmodel ADD CONSTRAINT tests_somerelatedmodel_pkey PRIMARY KEY (related_tenant_id, id);", 20 | ), 21 | ] 22 | 23 | if settings.USE_CITUS: 24 | operations += [ 25 | tenant_migrations.Distribute("TenantNotIdModel"), 26 | tenant_migrations.Distribute("SomeRelatedModel"), 27 | ] 28 | 29 | operations += [ 30 | migrations.RunSQL( 31 | "ALTER TABLE tests_somerelatedmodel ADD CONSTRAINT tests_somerelatedmodel_pkey PRIMARY KEY (related_tenant_id, id);", 32 | reverse_sql="ALTER TABLE tests_somerelatedmodel DROP CONSTRAINT tests_somerelatedmodel_pkey CASCADE;", 33 | ), 34 | migrations.RunSQL( 35 | "ALTER TABLE tests_tenantnotidmodel ADD CONSTRAINT tests_tenantnotidmodel_pkey PRIMARY KEY (tenant_column);", 36 | reverse_sql="ALTER TABLE tests_tenantnotidmodel DROP CONSTRAINT tests_tenantnotidmodel_pkey CASCADE;", 37 | ), 38 | ] 39 | 40 | return operations 41 | 42 | 43 | class Migration(migrations.Migration): 44 | dependencies = [ 45 | ("tests", "0002_distribute"), 46 | ] 47 | 48 | operations = get_operations() 49 | -------------------------------------------------------------------------------- /requirements/release-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/release-requirements.txt --resolver=backtracking requirements/release.in 6 | # 7 | build==1.0.3 8 | # via -r requirements/release.in 9 | certifi==2024.7.4 10 | # via requests 11 | cffi==1.15.1 12 | # via cryptography 13 | charset-normalizer==3.2.0 14 | # via requests 15 | cryptography==44.0.1 16 | # via secretstorage 17 | docutils==0.20.1 18 | # via readme-renderer 19 | idna==3.7 20 | # via requests 21 | importlib-metadata==6.8.0 22 | # via 23 | # build 24 | # keyring 25 | # twine 26 | importlib-resources==6.1.0 27 | # via keyring 28 | jaraco-classes==3.3.0 29 | # via keyring 30 | jeepney==0.8.0 31 | # via 32 | # keyring 33 | # secretstorage 34 | keyring==24.2.0 35 | # via twine 36 | markdown-it-py==3.0.0 37 | # via rich 38 | mdurl==0.1.2 39 | # via markdown-it-py 40 | more-itertools==10.1.0 41 | # via jaraco-classes 42 | nh3==0.2.14 43 | # via readme-renderer 44 | packaging==23.1 45 | # via build 46 | pkginfo==1.9.6 47 | # via twine 48 | pycparser==2.21 49 | # via cffi 50 | pygments==2.16.1 51 | # via 52 | # readme-renderer 53 | # rich 54 | pyproject-hooks==1.0.0 55 | # via build 56 | readme-renderer==42.0 57 | # via twine 58 | requests==2.32.4 59 | # via 60 | # requests-toolbelt 61 | # twine 62 | requests-toolbelt==1.0.0 63 | # via twine 64 | rfc3986==2.0.0 65 | # via twine 66 | rich==13.5.3 67 | # via twine 68 | secretstorage==3.3.3 69 | # via keyring 70 | tomli==2.0.1 71 | # via 72 | # build 73 | # pyproject-hooks 74 | twine==4.0.2 75 | # via -r requirements/release.in 76 | typing-extensions==4.8.0 77 | # via rich 78 | urllib3==2.6.0 79 | # via 80 | # requests 81 | # twine 82 | zipp==3.19.1 83 | # via 84 | # importlib-metadata 85 | # importlib-resources 86 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export DJANGO_SETTINGS_MODULE=django_multitenant.tests.settings 2 | 3 | test-dependencies: 4 | pip install -r requirements/test-requirements.txt 5 | pip install Django=="${DJANGO_VERSION}" 6 | pip install djangorestframework 7 | 8 | test: 9 | py.test --cov-report xml --cov=django_multitenant/tests/. -s django_multitenant/tests/ -k 'not concurrency' --ignore django_multitenant/tests/test_missing_modules.py 10 | 11 | test-missing-modules: 12 | # Test that the package works without the djangorestframework 13 | pip uninstall -y djangorestframework 14 | 15 | # We need to remove the rest_framework from the settings.py. 16 | # Normally, application without rest_framework will not be installed in setting file. 17 | # In our application we are installing rest_framework in test settings file to test the rest_framework related tasks. 18 | cp django_multitenant/tests/settings.py django_multitenant/tests/settings.py.bak 19 | sed -i '/INSTALLED_APPS/{n; /rest_framework/d;}' django_multitenant/tests/settings.py 20 | py.test -s --cov-report xml --cov=django_multitenant/tests/. django_multitenant/tests/test_missing_modules.py -k 'not concurrency' 21 | # Revert the changes in settings.py 22 | mv django_multitenant/tests/settings.py.bak django_multitenant/tests/settings.py 23 | 24 | test-migrations: 25 | ./manage.py migrate tests 26 | 27 | revert-test-migrations: 28 | ./manage.py migrate tests 0002_distribute 29 | # We fake the 0002_distribute rollback, because it uses lots of raw sql and 30 | # otherwise we have to add reverse_sql='' everywhere. The backwards 31 | # migration of 0001_initial will drop the tables anyway. 32 | ./manage.py migrate --fake tests 0001_initial 33 | ./manage.py migrate tests zero 34 | 35 | release-dependencies: 36 | pip install -r requirements/release-requirements.txt 37 | 38 | format: 39 | black . 40 | 41 | format-check: 42 | black --version 43 | black . --check 44 | 45 | lint: 46 | prospector -X --profile-path .prospector.yaml 47 | 48 | release: 49 | python -m build --sdist 50 | twine check dist/* 51 | twine upload --skip-existing dist/* 52 | 53 | test-model: 54 | py.test -s django_multitenant/tests/test_models.py -k 'not concurrency' 55 | 56 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0017_auto_20200128_0853.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-28 08:53 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | from django.conf import settings 6 | 7 | from django_multitenant.db import migrations as tenant_migrations 8 | 9 | 10 | def get_operations(): 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Employee", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("name", models.CharField(max_length=255)), 25 | ( 26 | "account", 27 | models.ForeignKey( 28 | blank=True, 29 | null=True, 30 | on_delete=django.db.models.deletion.CASCADE, 31 | related_name="employees", 32 | db_constraint=False, 33 | to="tests.Account", 34 | ), 35 | ), 36 | ( 37 | "created_by", 38 | models.ForeignKey( 39 | blank=True, 40 | null=True, 41 | on_delete=django.db.models.deletion.SET_NULL, 42 | related_name="users_created", 43 | to="tests.Employee", 44 | ), 45 | ), 46 | ], 47 | ), 48 | ] 49 | 50 | if settings.USE_CITUS: 51 | operations += [ 52 | tenant_migrations.Distribute( 53 | "Employee", reference=True, reverse_ignore=True 54 | ), 55 | ] 56 | 57 | return operations 58 | 59 | 60 | class Migration(migrations.Migration): 61 | dependencies = [ 62 | ("tests", "0016_auto_20191025_0844"), 63 | ] 64 | 65 | operations = get_operations() 66 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0023_auto_20200412_0603.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.20 on 2020-04-12 06:03 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django_multitenant.fields 8 | import django_multitenant.mixins 9 | 10 | from django_multitenant.db import migrations as tenant_migrations 11 | 12 | 13 | class Migration(migrations.Migration): 14 | dependencies = [ 15 | ("tests", "0022_merge_20200211_1000"), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="Revenue", 21 | fields=[ 22 | ( 23 | "id", 24 | models.BigAutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ("value", models.CharField(max_length=30)), 32 | ( 33 | "acc", 34 | models.ForeignKey( 35 | on_delete=django.db.models.deletion.CASCADE, 36 | related_name="revenues", 37 | to="tests.Account", 38 | ), 39 | ), 40 | ( 41 | "project", 42 | django_multitenant.fields.TenantForeignKey( 43 | on_delete=django.db.models.deletion.CASCADE, 44 | related_name="revenues", 45 | to="tests.Project", 46 | ), 47 | ), 48 | ], 49 | options={ 50 | "abstract": False, 51 | }, 52 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 53 | ), 54 | migrations.RunSQL( 55 | "ALTER TABLE tests_revenue DROP CONSTRAINT tests_revenue_pkey CASCADE;", 56 | reverse_sql="", 57 | ), 58 | tenant_migrations.Distribute("Revenue", reverse_ignore=True), 59 | migrations.RunSQL( 60 | "ALTER TABLE tests_revenue ADD CONSTRAINT tests_revenue_pkey PRIMARY KEY (acc_id, id);", 61 | reverse_sql="", 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Multi-tenant documentation master file, created by 2 | sphinx-quickstart on Mon Feb 13 13:32:28 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Multi-tenant's documentation! 7 | ================================================= 8 | 9 | |Latest Documentation Status| |Build Status| |Coverage Status| |PyPI Version| 10 | 11 | .. |Latest Documentation Status| image:: https://readthedocs.org/projects/django-multitenant/badge/?version=latest 12 | :target: https://django-multitenant.readthedocs.io/en/latest/?badge=latest 13 | :alt: Documentation Status 14 | 15 | .. |Build Status| image:: https://github.com/citusdata/django-multitenant/actions/workflows/django-multitenant-tests.yml/badge.svg 16 | :target: https://github.com/citusdata/django-multitenant/actions/workflows/django-multitenant-tests.yml 17 | :alt: Build Status 18 | 19 | .. |Coverage Status| image:: https://codecov.io/gh/citusdata/django-multitenant/branch/main/graph/badge.svg?token=taRgoSgHUw 20 | :target: https://codecov.io/gh/citusdata/django-multitenant 21 | :alt: Coverage Status 22 | 23 | .. |PyPI Version| image:: https://badge.fury.io/py/django-multitenant.svg 24 | :target: https://badge.fury.io/py/django-multitenant 25 | 26 | 27 | Python/Django support for distributed multi-tenant databases like 28 | Postgres+Citus 29 | 30 | Enables easy scale-out by adding the tenant context to your queries, 31 | enabling the database (e.g. Citus) to efficiently route queries to the 32 | right database node. 33 | 34 | There are architecures for building multi-tenant databases viz. **Create 35 | one database per tenant**, **Create one schema per tenant** and **Have 36 | all tenants share the same table(s)**. This library is based on the 3rd 37 | design i.e **Have all tenants share the same table(s)**, it assumes that 38 | all the tenant relates models/tables have a tenant_id column for 39 | representing a tenant. 40 | 41 | The following link talks more about the trade-offs on when and how to 42 | choose the right architecture for your multi-tenat database: 43 | 44 | https://www.citusdata.com/blog/2016/10/03/designing-your-saas-database-for-high-scalability/ 45 | 46 | .. toctree:: 47 | :maxdepth: 2 48 | :caption: Table Of Contents 49 | 50 | general 51 | usage 52 | migration_mt_django 53 | django_rest_integration 54 | license 55 | -------------------------------------------------------------------------------- /django_multitenant/tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import django 4 | 5 | 6 | BASE_PATH = os.path.normpath( 7 | os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) 8 | ) 9 | 10 | if django.VERSION >= (4, 0): 11 | test_db = {"NAME": "postgres"} 12 | else: 13 | test_db = {"NAME": "postgres", "SERIALIZE": False} 14 | 15 | DATABASES = { 16 | "default": { 17 | "ENGINE": "django_multitenant.backends.postgresql", 18 | "NAME": "postgres", 19 | "USER": "postgres", 20 | "PASSWORD": "", 21 | "HOST": os.environ.get("DATABASE_HOST", "localhost"), 22 | "PORT": int(os.environ.get("DATABASE_PORT", "5600")), 23 | "TEST": test_db, 24 | } 25 | } 26 | 27 | SITE_ID = 1 28 | DEBUG = True 29 | 30 | MIDDLEWARE = ( 31 | "django.contrib.sessions.middleware.SessionMiddleware", 32 | "django.middleware.locale.LocaleMiddleware", 33 | "django.middleware.common.CommonMiddleware", 34 | "django.middleware.csrf.CsrfViewMiddleware", 35 | "django.contrib.auth.middleware.AuthenticationMiddleware", 36 | "django.contrib.messages.middleware.MessageMiddleware", 37 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 38 | "django_multitenant.middlewares.MultitenantMiddleware", 39 | ) 40 | 41 | 42 | INSTALLED_APPS = [ 43 | "rest_framework", 44 | "django.contrib.admin", 45 | "django.contrib.messages", 46 | "django.contrib.auth", 47 | "django.contrib.contenttypes", 48 | "django.contrib.sessions", 49 | "django.contrib.sites", 50 | "django_multitenant", 51 | "django_multitenant.tests", 52 | ] 53 | 54 | SECRET_KEY = "blabla" 55 | 56 | ROOT_URLCONF = "django_multitenant.tests.urls" 57 | 58 | 59 | TEMPLATES = [ 60 | { 61 | "BACKEND": "django.template.backends.django.DjangoTemplates", 62 | "DIRS": [], 63 | "APP_DIRS": True, 64 | "OPTIONS": { 65 | "context_processors": [ 66 | "django.template.context_processors.debug", 67 | "django.template.context_processors.request", 68 | "django.contrib.auth.context_processors.auth", 69 | "django.contrib.messages.context_processors.messages", 70 | "django.template.context_processors.i18n", 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | USE_CITUS = True 77 | CITUS_EXTENSION_INSTALLED = True 78 | 79 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 80 | USE_TZ = True 81 | 82 | TENANT_USE_ASGIREF = False 83 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0018_auto_20200128_0902.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-28 09:02 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django_multitenant.fields 7 | import django_multitenant.mixins 8 | 9 | from django_multitenant.db import migrations as tenant_migrations 10 | 11 | 12 | def get_operations(): 13 | operations = [ 14 | migrations.CreateModel( 15 | name="ModelConfig", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ( 28 | "account", 29 | models.ForeignKey( 30 | on_delete=django.db.models.deletion.CASCADE, 31 | related_name="configs", 32 | to="tests.Account", 33 | ), 34 | ), 35 | ( 36 | "employee", 37 | models.ForeignKey( 38 | on_delete=django.db.models.deletion.CASCADE, 39 | related_name="configs", 40 | to="tests.Employee", 41 | ), 42 | ), 43 | ], 44 | options={ 45 | "abstract": False, 46 | }, 47 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 48 | ), 49 | migrations.RunSQL( 50 | "ALTER TABLE tests_modelconfig DROP CONSTRAINT tests_modelconfig_pkey CASCADE;", 51 | reverse_sql="", 52 | ), 53 | migrations.RunSQL( 54 | "ALTER TABLE tests_modelconfig ADD CONSTRAINT tests_modelconfig_pkey PRIMARY KEY (account_id, id);", 55 | reverse_sql="", 56 | ), 57 | ] 58 | 59 | if settings.USE_CITUS: 60 | operations += [ 61 | tenant_migrations.Distribute("ModelConfig", reverse_ignore=True), 62 | ] 63 | 64 | return operations 65 | 66 | 67 | class Migration(migrations.Migration): 68 | atomic = False 69 | 70 | dependencies = [ 71 | ("tests", "0017_auto_20200128_0853"), 72 | ] 73 | 74 | operations = get_operations() 75 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0025_data_load.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-02-20 07:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tests", "0024_business_tenant_alter_account_id_and_more"), 9 | ] 10 | 11 | def forwards_func(apps, schema_editor): 12 | db_alias = schema_editor.connection.alias 13 | Country = apps.get_model("tests", "Country") 14 | 15 | Country.objects.using(db_alias).bulk_create( 16 | [ 17 | Country(name="USA"), 18 | Country(name="France"), 19 | Country(name="Turkiye"), 20 | ] 21 | ) 22 | 23 | Account = apps.get_model("tests", "Account") 24 | Account.objects.using(db_alias).bulk_create( 25 | [ 26 | Account( 27 | name="johndoe", 28 | domain="domain", 29 | subdomain="subdomain", 30 | country=Country.objects.get(name="USA"), 31 | ), 32 | Account( 33 | name="jilldoe", 34 | domain="domain", 35 | subdomain="subdomain", 36 | country=Country.objects.get(name="USA"), 37 | ), 38 | Account( 39 | name="milldoe", 40 | domain="domain", 41 | subdomain="subdomain", 42 | country=Country.objects.get(name="USA"), 43 | ), 44 | Account( 45 | name="alidoe", 46 | domain="domain", 47 | subdomain="subdomain", 48 | country=Country.objects.get(name="Turkiye"), 49 | ), 50 | Account( 51 | name="velidoe", 52 | domain="domain", 53 | subdomain="subdomain", 54 | country=Country.objects.get(name="Turkiye"), 55 | ), 56 | Account( 57 | name="pierredoe", 58 | domain="domain", 59 | subdomain="subdomain", 60 | country=Country.objects.get(name="France"), 61 | ), 62 | ] 63 | ) 64 | country = Country.objects.get(name="USA") 65 | accounts = Account.objects.all() 66 | 67 | assert len(accounts) == 6 68 | assert country is not None 69 | 70 | operations = [migrations.RunPython(forwards_func)] 71 | -------------------------------------------------------------------------------- /django_multitenant/db/migrations/distribute.py: -------------------------------------------------------------------------------- 1 | from django.apps.registry import apps as global_apps 2 | from django.db.migrations.operations.base import Operation 3 | from django.db.migrations.state import _get_app_label_and_model_name 4 | 5 | from django_multitenant.utils import get_tenant_column 6 | 7 | 8 | class Distribute(Operation): 9 | def __init__(self, model_name, reference=False, reverse_ignore=False): 10 | self.reverse_ignore = reverse_ignore 11 | self.model_name = model_name 12 | self.reference = reference 13 | 14 | def get_query(self): 15 | if self.reference: 16 | return "SELECT create_reference_table(%s)" 17 | 18 | return "SELECT create_distributed_table(%s, %s)" 19 | 20 | def state_forwards(self, app_label, state): 21 | # Distribute objects have no state effect. 22 | pass 23 | 24 | def get_fake_model(self, app_label, from_state): 25 | # The following step is necessary because if we distribute a table from a different app 26 | # than the one in which the migrations/00XX_distribute.py is, we need to load the different 27 | # app to get the model 28 | new_app_label, self.model_name = _get_app_label_and_model_name(self.model_name) 29 | app_label = new_app_label or app_label 30 | 31 | # Using the current state of the migration, it retrieves a class '__fake__.ModelName' 32 | # As it's using the state, if the model has been deleted since, it will still find the model 33 | # This will fail if it's trying to find a model that never existed 34 | return from_state.apps.get_model(app_label, self.model_name) 35 | 36 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 37 | if self.reverse_ignore: 38 | return 39 | fake_model = self.get_fake_model(app_label, from_state) 40 | 41 | self.args = [fake_model._meta.db_table] 42 | 43 | schema_editor.execute("SELECT undistribute_table(%s)", params=self.args) 44 | 45 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 46 | fake_model = self.get_fake_model(app_label, from_state) 47 | 48 | # We now need the real model, the problem is that the __fake__ model doesn't have access 49 | # to anything else (functions / properties) than the Fields 50 | # So to access the model.tenant_id, we need this. 51 | app = global_apps.get_app_config( 52 | fake_model._meta.app_label if fake_model else app_label 53 | ) 54 | self.model = None 55 | 56 | for model in app.get_models(): 57 | if model.__name__ == self.model_name: 58 | self.model = model 59 | 60 | if fake_model and not self.model: 61 | # The model has been deleted 62 | # We can't distribute that table as we don't have the initial model anymore 63 | # So no idea what the tenant column is 64 | # However, as there are create foreign key constraints that will fail in further migrations 65 | # We need to make it a reference table 66 | self.reference = True 67 | 68 | self.args = [fake_model._meta.db_table] 69 | if not self.reference: 70 | self.args.append(get_tenant_column(self.model)) 71 | 72 | schema_editor.execute(self.get_query(), params=self.args) 73 | 74 | def describe(self): 75 | return "Run create_(distributed/reference)_table statement" 76 | -------------------------------------------------------------------------------- /django_multitenant/query.py: -------------------------------------------------------------------------------- 1 | from django.db import connections, transaction 2 | from django.db.models import Q 3 | from django.db.models.sql.constants import ( 4 | GET_ITERATOR_CHUNK_SIZE, 5 | NO_RESULTS, 6 | ) 7 | from django.conf import settings 8 | from django.db.models.sql.where import WhereNode 9 | 10 | 11 | from .utils import ( 12 | get_current_tenant, 13 | get_tenant_filters, 14 | is_distributed_model, 15 | ) 16 | 17 | 18 | def add_tenant_filters_on_query(obj): 19 | current_tenant = get_current_tenant() 20 | 21 | if current_tenant: 22 | try: 23 | filters = get_tenant_filters(obj.model) 24 | obj.add_q(Q(**filters)) 25 | except ValueError: 26 | pass 27 | 28 | 29 | def wrap_get_compiler(base_get_compiler): 30 | # Adds tenant filters to the query object 31 | def get_compiler(obj, *args, **kwargs): 32 | add_tenant_filters_on_query(obj) 33 | return base_get_compiler(obj, *args, **kwargs) 34 | 35 | # pylint: disable=protected-access 36 | get_compiler._sign = "get_compiler django-multitenant" 37 | return get_compiler 38 | 39 | 40 | # pylint: disable=unused-argument 41 | def wrap_update_batch(base_update_batch): 42 | # Written to decorate the update_batch method of the UpdateQuery class to add tenant_id filters. 43 | # Since add tenant_filters method must be executed before the execute_sql method, we have to 44 | # copy and rewrite the update_batch method. 45 | # CAUTION: Since source is copied, UpdateQuery.update_batch method should be tracked 46 | def update_batch(obj, pk_list, values, using): 47 | obj.add_update_values(values) 48 | for offset in range(0, len(pk_list), GET_ITERATOR_CHUNK_SIZE): 49 | obj.where = WhereNode() 50 | obj.add_q(Q(pk__in=pk_list[offset : offset + GET_ITERATOR_CHUNK_SIZE])) 51 | add_tenant_filters_on_query(obj) 52 | obj.get_compiler(using).execute_sql(NO_RESULTS) 53 | 54 | # pylint: disable=protected-access 55 | update_batch._sign = "update_batch django-multitenant" 56 | return update_batch 57 | 58 | 59 | def wrap_delete(base_delete): 60 | def delete(obj): 61 | obj_are_distributed = [ 62 | is_distributed_model(instance) for instance in obj.data.keys() 63 | ] 64 | 65 | # If all elements are from distributed tables, then we can do a simple atomic transaction. 66 | # If there are mixes of distributed and reference tables, it would raise the following error : 67 | 68 | # django.db.utils.InternalError: cannot execute DML on reference relation 69 | # "table" because there was a parallel DML access to distributed relation 70 | # "table2" in the same transaction 71 | 72 | if len(set(obj_are_distributed)) > 1 and getattr( 73 | settings, "CITUS_EXTENSION_INSTALLED", False 74 | ): 75 | # all elements are not the same, some False, some True 76 | with transaction.atomic(using=obj.using, savepoint=False): 77 | connections[obj.using].cursor().execute( 78 | "SET LOCAL citus.multi_shard_modify_mode TO 'sequential';" 79 | ) 80 | result = base_delete(obj) 81 | connections[obj.using].cursor().execute( 82 | "SET LOCAL citus.multi_shard_modify_mode TO 'parallel';" 83 | ) 84 | return result 85 | 86 | return base_delete(obj) 87 | 88 | return delete 89 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Django-Multitenant v4.1.1 (December 18, 2023) ### 2 | 3 | * Fix utils to not require TENANT_USE_ASGIREF to be defined in the host django project (#206) 4 | 5 | ### Django-Multitenant v4.1.0 (December 14, 2023) ### 6 | 7 | * Use asgiref when available instead of thread locals (#176) (#198) 8 | 9 | ### Django-Multitenant v4.0.0 (September 26, 2023) ### 10 | 11 | * Fixes citus 11.3 identity column bigint constraint (#181) 12 | 13 | * Adds new python versions for dj3.2 (#188) 14 | 15 | * Adds Citus 12 and Django 4.1 and 4.2 support (#197) 16 | 17 | ### Django-Multitenant v3.2.1 (April 10, 2023) ### 18 | 19 | * Add m2m with no through_defaults fix (#170) 20 | 21 | ### Django-Multitenant v3.2.0 (March 29, 2023) ### 22 | 23 | * Adds DjangoRestFramework support (#157) 24 | 25 | * Adds guidelines to get model in migration (#167) 26 | 27 | ### Django-Multitenant v3.1.1 (March 15, 2023) ### 28 | 29 | * Fixes #164 ManyToMany Non tenant model save issue 30 | 31 | ### Django-Multitenant v3.1.0(March 1, 2023) ### 32 | 33 | * Adds support for Django 4.1 34 | 35 | * Adds support for setting tenant automatically for ManyToMany related model 36 | 37 | * Fixes invalid error message problem in case of invalid field name 38 | 39 | * Adds support for getting models using apps.get_model 40 | 41 | * Removes reserved tenant_id limitation by introducing TenantMeta usage 42 | 43 | * Introduces ReadTheDocs documentation 44 | 45 | ### Django-Multitenant v3.0.0(December 8, 2021) ### 46 | 47 | * Adds support for Django 4.0 48 | 49 | * Drops support for the following EOLed Django and Python versions: 50 | 1. Python 2.7 51 | 2. Django 1.11 52 | 3. Django 3.1 53 | 54 | ### Django-Multitenant v2.4.0(November 11, 2021) ### 55 | 56 | * Backwards migration for `Distribute` migration using `undistribute_table()` 57 | 58 | * Adds tests for Django 3.2 and Python 3.9 59 | 60 | * Fixes migrations on Django 3.0+ 61 | 62 | * Fixes aggregations using `annotate` 63 | 64 | ### Django-Multitenant v2.0.9 (May 18, 2019) ### 65 | 66 | * Fixes the process of running old migrations when the model has been deleted from the code. 67 | 68 | ### Django-Multitenant v2.0.8 (May 18, 2019) ### 69 | 70 | * Add tests to confirm the join condition in subqueries includes tenant column. 71 | 72 | ### Django-Multitenant v2.0.7 (May 18, 2019) ### 73 | 74 | * Fixes create with current tenant 75 | 76 | ### Django-Multitenant v2.0.6 (May 18, 2019) ### 77 | 78 | * Fix recursive loop in warning for fields when joining without current_tenant set 79 | 80 | ### Django-Multitenant v2.0.5 (May 18, 2019) ### 81 | 82 | * Adds support for custom query_set in TenantManager 83 | 84 | * Cleans the delete code to ensure deleting rows only related to current tenant 85 | 86 | ### Django-Multitenant v2.0.4 (May 18, 2019) ### 87 | 88 | * Adds support for multiple tenant 89 | 90 | ### Django-Multitenant v1.1.0 (January 26, 2018) ### 91 | 92 | * Add TenantForeignKey to emulate composite foreign keys between tenant related models. 93 | 94 | * Split apart library into multiple files. Importing the utility function `get_current_tenant` would cause errors due to the import statement triggering evaluation of the TenantModel class. This would cause problems if TenantModel were evaluated before the database backend was initialized. 95 | 96 | * Added a simple TenantOneToOneField which does not try to enforce a uniqueness constraint on the ID column, but preserves all the relationship semantics of using a traditional OneToOneField in Django. 97 | 98 | * Overrode Django's DatabaseSchemaEditor to produce foreign key constraints on composite foreign keys consisting of both the ID and tenant ID columns for any foreign key between TenantModels 99 | 100 | * Monkey-patched Django's DeleteQuery implementation to include tenant_id in its SQL queries. 101 | 102 | ### Django-Multitenant v1.0.1 (November 7, 2017) ### 103 | 104 | * Some bug fixes. 105 | -------------------------------------------------------------------------------- /django_multitenant/tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | 3 | from django.db.migrations.state import ProjectState 4 | from django.conf import settings 5 | from django.test import TransactionTestCase 6 | 7 | from django_multitenant.db import migrations 8 | from .utils import undistribute_table, is_table_distributed 9 | 10 | 11 | if settings.USE_CITUS: 12 | 13 | class MigrationTest(TransactionTestCase): 14 | def assertTableIsDistributed(self, table_name, column_name, value=True): 15 | distributed = is_table_distributed(connection, table_name, column_name) 16 | 17 | self.assertEqual(distributed, value) 18 | 19 | def assertTableIsNotDistributed(self, table_name, column_name): 20 | return self.assertTableIsDistributed(table_name, column_name, value=False) 21 | 22 | def assertTableIsReference(self, table_name, value=False): 23 | query = """ 24 | SELECT 1 25 | FROM pg_dist_partition 26 | WHERE logicalrelid::varchar(255) = %s 27 | AND partmethod='m'; 28 | """ 29 | distributed = False 30 | 31 | with connection.cursor() as cursor: 32 | cursor.execute(query, [table_name]) 33 | row = cursor.fetchone() 34 | 35 | distributed = bool(row) 36 | 37 | self.assertEqual(distributed, value) 38 | 39 | def assertTableIsNotReference(self, table_name): 40 | return self.assertTableIsReference(table_name, value=False) 41 | 42 | def test_distribute_table(self): 43 | project_state = ProjectState(real_apps={"tests"}) 44 | operation = migrations.Distribute("MigrationTestModel") 45 | 46 | self.assertEqual( 47 | operation.describe(), 48 | "Run create_(distributed/reference)_table statement", 49 | ) 50 | 51 | self.assertTableIsNotDistributed("tests_migrationtestmodel", "id") 52 | new_state = project_state.clone() 53 | 54 | with connection.schema_editor() as editor: 55 | operation.database_forwards("tests", editor, project_state, new_state) 56 | 57 | self.assertTableIsDistributed("tests_migrationtestmodel", "id") 58 | undistribute_table(connection, "tests_migrationtestmodel") 59 | 60 | def test_reference_table(self): 61 | project_state = ProjectState(real_apps={"tests"}) 62 | operation = migrations.Distribute( 63 | "MigrationTestReferenceModel", reference=True 64 | ) 65 | 66 | self.assertEqual( 67 | operation.describe(), 68 | "Run create_(distributed/reference)_table statement", 69 | ) 70 | self.assertTableIsNotReference("tests_migrationtestreferencemodel") 71 | new_state = project_state.clone() 72 | 73 | with connection.schema_editor() as editor: 74 | operation.database_forwards("tests", editor, project_state, new_state) 75 | 76 | self.assertTableIsReference("tests_migrationtestreferencemodel") 77 | undistribute_table(connection, "tests_migrationtestreferencemodel") 78 | 79 | def test_reference_different_app_table(self): 80 | project_state = ProjectState(real_apps={"auth"}) 81 | operation = migrations.Distribute("auth.Group", reference=True) 82 | 83 | self.assertEqual( 84 | operation.describe(), 85 | "Run create_(distributed/reference)_table statement", 86 | ) 87 | self.assertTableIsNotReference("auth_group") 88 | new_state = project_state.clone() 89 | 90 | with connection.schema_editor() as editor: 91 | operation.database_forwards("tests", editor, project_state, new_state) 92 | 93 | self.assertTableIsReference("auth_group") 94 | undistribute_table(connection, "auth_group") 95 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0024_business_tenant_alter_account_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-02-17 11:14 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django_multitenant.fields 6 | import django_multitenant.mixins 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("tests", "0023_auto_20200412_0603"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Business", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("bk_biz_id", models.IntegerField(verbose_name="business ID")), 28 | ( 29 | "bk_biz_name", 30 | models.CharField(max_length=100, verbose_name="business name"), 31 | ), 32 | ], 33 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 34 | ), 35 | migrations.CreateModel( 36 | name="Tenant", 37 | fields=[ 38 | ( 39 | "id", 40 | models.BigAutoField( 41 | auto_created=True, 42 | primary_key=True, 43 | serialize=False, 44 | verbose_name="ID", 45 | ), 46 | ), 47 | ("name", models.CharField(max_length=100, verbose_name="tenant name")), 48 | ], 49 | options={ 50 | "abstract": False, 51 | }, 52 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 53 | ), 54 | migrations.CreateModel( 55 | name="Template", 56 | fields=[ 57 | ( 58 | "id", 59 | models.BigAutoField( 60 | auto_created=True, 61 | primary_key=True, 62 | serialize=False, 63 | verbose_name="ID", 64 | ), 65 | ), 66 | ("name", models.CharField(max_length=100, verbose_name="name")), 67 | ( 68 | "business", 69 | django_multitenant.fields.TenantForeignKey( 70 | blank=True, 71 | null=True, 72 | on_delete=django.db.models.deletion.SET_NULL, 73 | to="tests.business", 74 | ), 75 | ), 76 | ( 77 | "tenant", 78 | models.ForeignKey( 79 | blank=True, 80 | null=True, 81 | on_delete=django.db.models.deletion.SET_NULL, 82 | to="tests.tenant", 83 | ), 84 | ), 85 | ], 86 | options={ 87 | "abstract": False, 88 | }, 89 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 90 | ), 91 | migrations.AddField( 92 | model_name="business", 93 | name="tenant", 94 | field=models.ForeignKey( 95 | blank=True, 96 | null=True, 97 | on_delete=django.db.models.deletion.SET_NULL, 98 | to="tests.tenant", 99 | ), 100 | ), 101 | migrations.AddConstraint( 102 | model_name="business", 103 | constraint=models.UniqueConstraint( 104 | fields=("id", "tenant_id"), name="unique_business_tenant" 105 | ), 106 | ), 107 | ] 108 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0002_distribute.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-11-13 16:01 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.conf import settings 7 | 8 | from django_multitenant.db import migrations as tenant_migrations 9 | 10 | 11 | def get_operations(): 12 | operations = [] 13 | if settings.USE_CITUS: 14 | operations = [ 15 | # necessary for tests 16 | migrations.RunSQL("CREATE EXTENSION IF NOT EXISTS citus;"), 17 | migrations.RunSQL( 18 | "SELECT * from master_add_node('django-multitenant_worker1_1', 5432);" 19 | ), 20 | migrations.RunSQL( 21 | "SELECT * from master_add_node('django-multitenant_worker2_1', 5432);" 22 | ), 23 | ] 24 | 25 | operations += [ 26 | # Drop constraints 27 | migrations.RunSQL( 28 | """ 29 | ALTER TABLE tests_aliasedtask 30 | DROP CONSTRAINT tests_aliasedtask_pkey CASCADE; 31 | 32 | ALTER TABLE tests_aliasedtask ADD CONSTRAINT 33 | tests_aliasedtask_pkey PRIMARY KEY (account_id, id); 34 | """ 35 | ), 36 | migrations.RunSQL( 37 | "ALTER TABLE tests_country DROP CONSTRAINT tests_country_pkey CASCADE;" 38 | ), 39 | migrations.RunSQL( 40 | "ALTER TABLE tests_manager DROP CONSTRAINT tests_manager_pkey CASCADE;" 41 | ), 42 | migrations.RunSQL( 43 | "ALTER TABLE tests_project DROP CONSTRAINT tests_project_pkey CASCADE;" 44 | ), 45 | migrations.RunSQL( 46 | "ALTER TABLE tests_projectmanager DROP CONSTRAINT tests_projectmanager_pkey CASCADE;" 47 | ), 48 | migrations.RunSQL( 49 | "ALTER TABLE tests_record DROP CONSTRAINT tests_record_pkey CASCADE;" 50 | ), 51 | migrations.RunSQL( 52 | "ALTER TABLE tests_subtask DROP CONSTRAINT tests_subtask_pkey CASCADE;" 53 | ), 54 | migrations.RunSQL( 55 | "ALTER TABLE tests_task DROP CONSTRAINT tests_task_pkey CASCADE;" 56 | ), 57 | ] 58 | 59 | if settings.USE_CITUS: 60 | # distribute 61 | operations += [ 62 | tenant_migrations.Distribute("Country", reference=True), 63 | tenant_migrations.Distribute("Account"), 64 | tenant_migrations.Distribute("AliasedTask"), 65 | tenant_migrations.Distribute("Manager"), 66 | tenant_migrations.Distribute("Organization"), 67 | tenant_migrations.Distribute("Project"), 68 | tenant_migrations.Distribute("ProjectManager"), 69 | tenant_migrations.Distribute("Record"), 70 | tenant_migrations.Distribute("SubTask"), 71 | tenant_migrations.Distribute("Task"), 72 | ] 73 | 74 | # Add constraints 75 | operations += [ 76 | migrations.RunSQL( 77 | "ALTER TABLE tests_country ADD CONSTRAINT tests_country_pkey PRIMARY KEY (id);" 78 | ), 79 | migrations.RunSQL( 80 | "ALTER TABLE tests_project ADD CONSTRAINT tests_project_pkey PRIMARY KEY (account_id, id);" 81 | ), 82 | migrations.RunSQL( 83 | "ALTER TABLE tests_manager ADD CONSTRAINT tests_manager_pkey PRIMARY KEY (account_id, id);" 84 | ), 85 | migrations.RunSQL( 86 | "ALTER TABLE tests_projectmanager ADD CONSTRAINT tests_projectmanager_pkey PRIMARY KEY (account_id, id);" 87 | ), 88 | migrations.RunSQL( 89 | "ALTER TABLE tests_record ADD CONSTRAINT tests_record_pkey PRIMARY KEY (organization_id, id);" 90 | ), 91 | migrations.RunSQL( 92 | "ALTER TABLE tests_subtask ADD CONSTRAINT tests_subtask_pkey PRIMARY KEY (account_id, id);" 93 | ), 94 | migrations.RunSQL( 95 | "ALTER TABLE tests_task ADD CONSTRAINT tests_task_pkey PRIMARY KEY (account_id, id);" 96 | ), 97 | ] 98 | 99 | return operations 100 | 101 | 102 | class Migration(migrations.Migration): 103 | atomic = False 104 | 105 | dependencies = [ 106 | ("tests", "0001_initial"), 107 | ] 108 | 109 | operations = get_operations() 110 | -------------------------------------------------------------------------------- /django_multitenant/fields.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import django 3 | from django.db import models 4 | from django.db.models.sql.where import WhereNode 5 | from django.conf import settings 6 | 7 | from .exceptions import EmptyTenant 8 | from .utils import get_current_tenant, get_tenant_column, get_tenant_filters 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class TenantForeignKey(models.ForeignKey): 14 | """ 15 | Should be used in place of models.ForeignKey for all foreign key relationships to 16 | subclasses of TenantModel. 17 | 18 | Adds additional clause to JOINs over this relation to include tenant_id in the JOIN 19 | on the TenantModel. 20 | 21 | Adds clause to forward accesses through this field to include tenant_id in the 22 | TenantModel lookup. 23 | """ 24 | 25 | # Override 26 | def get_extra_descriptor_filter(self, instance): 27 | """ 28 | Return an extra filter condition for related object fetching when 29 | user does 'instance.fieldname', that is the extra filter is used in 30 | the descriptor of the field. 31 | 32 | The filter should be either a dict usable in .filter(**kwargs) call or 33 | a Q-object. The condition will be ANDed together with the relation's 34 | joining columns. 35 | 36 | A parallel method is get_extra_restriction() which is used in 37 | JOIN and subquery conditions. 38 | """ 39 | 40 | current_tenant = get_current_tenant() 41 | if current_tenant: 42 | return get_tenant_filters(self.related_model) 43 | 44 | empty_tenant_message = ( 45 | f"TenantForeignKey field {self.model.__name__}.{self.name} " 46 | "accessed without a current tenant set. " 47 | "This may cause issues in a partitioned environment. " 48 | "Recommend calling set_current_tenant() before accessing " 49 | "this field." 50 | ) 51 | 52 | if getattr(settings, "TENANT_STRICT_MODE", False): 53 | raise EmptyTenant(empty_tenant_message) 54 | 55 | logger.warning(empty_tenant_message) 56 | return super().get_extra_descriptor_filter(instance) 57 | 58 | # Override 59 | # Django 4.0 removed the where_class argument from this method, so 60 | # depending on the version we define the function with a different 61 | # signature. 62 | # pylint: disable=unused-argument,arguments-differ 63 | 64 | if django.VERSION >= (4, 0): 65 | 66 | def get_extra_restriction(self, alias, related_alias): 67 | return self.get_extra_restriction_citus(alias, related_alias) 68 | 69 | else: 70 | 71 | def get_extra_restriction(self, where_class, alias, related_alias): 72 | return self.get_extra_restriction_citus(alias, related_alias) 73 | 74 | def get_extra_restriction_citus(self, alias, related_alias): 75 | """ 76 | Return a pair condition used for joining and subquery pushdown. The 77 | condition is something that responds to as_sql(compiler, connection) 78 | method. 79 | 80 | Note that currently referring both the 'alias' and 'related_alias' 81 | will not work in some conditions, like subquery pushdown. 82 | 83 | A parallel method is get_extra_descriptor_filter() which is used in 84 | instance.fieldname related object fetching. 85 | """ 86 | 87 | if not (related_alias and alias): 88 | return None 89 | 90 | # Fetch tenant column names for both sides of the relation 91 | lhs_model = self.model 92 | rhs_model = self.related_model 93 | lhs_tenant_id = get_tenant_column(lhs_model) 94 | rhs_tenant_id = get_tenant_column(rhs_model) 95 | 96 | # Fetch tenant fields for both sides of the relation 97 | lhs_tenant_field = lhs_model._meta.get_field(lhs_tenant_id) 98 | rhs_tenant_field = rhs_model._meta.get_field(rhs_tenant_id) 99 | 100 | # Get references to both tenant columns 101 | lookup_lhs = lhs_tenant_field.get_col(related_alias) 102 | lookup_rhs = rhs_tenant_field.get_col(alias) 103 | 104 | # Create "AND lhs.tenant_id = rhs.tenant_id" as a new condition 105 | lookup = lhs_tenant_field.get_lookup("exact")(lookup_lhs, lookup_rhs) 106 | condition = WhereNode() 107 | condition.add(lookup, "AND") 108 | return condition 109 | 110 | 111 | class TenantOneToOneField(models.OneToOneField, TenantForeignKey): 112 | # Override 113 | def __init__(self, *args, **kwargs): 114 | kwargs["unique"] = False 115 | super().__init__(*args, **kwargs) 116 | -------------------------------------------------------------------------------- /django_multitenant/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.apps import apps 4 | from .settings import TENANT_USE_ASGIREF 5 | 6 | 7 | if TENANT_USE_ASGIREF: 8 | # asgiref must be installed, its included with Django >= 3.0 9 | from asgiref.local import Local as local 10 | else: 11 | try: 12 | from threading import local 13 | except ImportError: 14 | from django.utils._threading_local import local 15 | 16 | 17 | _thread_locals = _context = local() 18 | 19 | 20 | def get_model_by_db_table(db_table): 21 | """ 22 | Gets django model using db_table name 23 | """ 24 | for model in apps.get_models(): 25 | if model._meta.db_table == db_table: 26 | return model 27 | 28 | # here you can do fallback logic if no model with db_table found 29 | raise ValueError(f"No model found with db_table {db_table}!") 30 | # or return None 31 | 32 | 33 | def get_current_tenant(): 34 | """ 35 | Utils to get the tenant that hass been set in the current thread/context using `set_current_tenant`. 36 | Can be used by doing: 37 | ``` 38 | my_class_object = get_current_tenant() 39 | ``` 40 | Will return None if the tenant is not set 41 | """ 42 | return getattr(_context, "tenant", None) 43 | 44 | 45 | def get_tenant_column(model_class_or_instance): 46 | """ 47 | Get the tenant field from the model object or class 48 | """ 49 | if inspect.isclass(model_class_or_instance): 50 | model_class_or_instance = model_class_or_instance() 51 | 52 | try: 53 | return model_class_or_instance.tenant_field 54 | except Exception as not_a_tenant_model: 55 | raise ValueError( 56 | f"{model_class_or_instance.__class__.__name__} is not an instance or a subclass of TenantModel or does not inherit from TenantMixin" 57 | ) from not_a_tenant_model 58 | 59 | 60 | def get_tenant_field(model_class_or_instance): 61 | """ 62 | Gets the tenant field object from the model 63 | """ 64 | tenant_column = get_tenant_column(model_class_or_instance) 65 | all_fields = model_class_or_instance._meta.fields 66 | try: 67 | return next(field for field in all_fields if field.column == tenant_column) 68 | except StopIteration as no_field_found: 69 | raise ValueError( 70 | f'No field found in {type(model_class_or_instance).name} with column name "{tenant_column}"' 71 | ) from no_field_found 72 | 73 | 74 | def get_object_tenant(instance): 75 | """ 76 | Gets the tenant value from the object. If the object itself is a tenant, it will return the same object 77 | """ 78 | field = get_tenant_field(instance) 79 | 80 | if field.primary_key: 81 | return instance 82 | 83 | return getattr(instance, field.name, None) 84 | 85 | 86 | def set_object_tenant(instance, value): 87 | if instance.tenant_value is None and value and not isinstance(value, list): 88 | setattr(instance, instance.tenant_field, value) 89 | 90 | 91 | def get_current_tenant_value(): 92 | """ 93 | Returns current set tenant value if exists 94 | If tenant is a list, it will return a list of tenant values 95 | If there is no tenant set, it will return None 96 | """ 97 | current_tenant = get_current_tenant() 98 | if not current_tenant: 99 | return None 100 | 101 | try: 102 | current_tenant = list(current_tenant) 103 | except TypeError: 104 | return current_tenant.tenant_value 105 | 106 | values = [] 107 | for t in current_tenant: 108 | values.append(t.tenant_value) 109 | return values 110 | 111 | 112 | def get_tenant_filters(table, filters=None): 113 | """ 114 | Returns filter with tenant column added to it if exists. 115 | If there is more than one tenant column, it will return fiter with in statement. 116 | """ 117 | filters = filters or {} 118 | 119 | current_tenant_value = get_current_tenant_value() 120 | 121 | if not current_tenant_value: 122 | return filters 123 | 124 | if isinstance(current_tenant_value, list): 125 | filters[f"{get_tenant_column(table)}__in"] = current_tenant_value 126 | else: 127 | filters[get_tenant_column(table)] = current_tenant_value 128 | 129 | return filters 130 | 131 | 132 | def set_current_tenant(tenant): 133 | """ 134 | Utils to set a tenant in the current thread/context. 135 | Often used in a middleware once a user is logged in to make sure all db 136 | calls are sharded to the current tenant. 137 | Can be used by doing: 138 | ``` 139 | get_current_tenant(my_class_object) 140 | ``` 141 | """ 142 | setattr(_context, "tenant", tenant) 143 | 144 | 145 | def unset_current_tenant(): 146 | setattr(_context, "tenant", None) 147 | 148 | 149 | def is_distributed_model(model): 150 | """ 151 | If model has tenant_field, it is distributed model and returns True 152 | """ 153 | try: 154 | get_tenant_field(model) 155 | return True 156 | except ValueError: 157 | return False 158 | -------------------------------------------------------------------------------- /.github/workflows/django-multitenant-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Django Multitenant Tests" 2 | on: 3 | pull_request: 4 | types: [ opened, reopened, synchronize ] 5 | workflow_dispatch: 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | static-checks: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.9 17 | - uses: actions/checkout@v3 18 | - name: Install python dependencies 19 | run: | 20 | pip install -r requirements/static-analysis-requirements.txt 21 | - name: Format Checks 22 | run: | 23 | make format-check 24 | - name: Prospector checks 25 | run: | 26 | make lint 27 | 28 | - name: Documentation Checks 29 | run: | 30 | cd docs 31 | sphinx-build -W -b html source builds 32 | 33 | 34 | 35 | tests-django-mt-lt-4_1: 36 | runs-on: ubuntu-latest 37 | name: "Python: ${{matrix.python_version}} | Django: ${{matrix.django_version}} | Citus: ${{matrix.citus_version}}" 38 | defaults: 39 | run: 40 | shell: bash 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | python_version: ["3.8","3.9","3.10","3.11"] 45 | django_version: ["3.2","4.0","4.1","4.2"] 46 | citus_version: ["11","12","13"] 47 | include: 48 | - python_version: "3.7" 49 | django_version: "3.2" 50 | citus_version: "11" 51 | - python_version: "3.7" 52 | django_version: "3.2" 53 | citus_version: "12" 54 | - python_version: "3.7" 55 | django_version: "3.2" 56 | citus_version: "13" 57 | env: 58 | PYTHON_VERSION: ${{ matrix.python_version }} 59 | CITUS_VERSION: ${{ matrix.citus_version }} 60 | DJANGO_VERSION: ${{ matrix.django_version }} 61 | steps: 62 | - uses: actions/checkout@v3 63 | - name: Test Django MT 64 | run: | 65 | docker compose --project-name django-multitenant up -d || { docker compose logs && false ; } 66 | echo "Running tests for python $PYTHON_VERSION, django $DJANGO_VERSION, citus $CITUS_VERSION" 67 | docker run \ 68 | --entrypoint /build/scripts/test-django-mt.sh \ 69 | -v $(pwd):/build \ 70 | --network django-multitenant_default \ 71 | -e PYTHON_VERSION -e DJANGO_VERSION \ 72 | -e DATABASE_HOST=django-multitenant_master \ 73 | -e DATABASE_PORT=5432 \ 74 | citusdata/python-all-versions 75 | - name: Upload coverage reports to Codecov 76 | uses: codecov/codecov-action@v3 77 | - name: Test Missing Modules 78 | run: | 79 | echo "Running tests for python $PYTHON_VERSION, django $DJANGO_VERSION, citus $CITUS_VERSION" 80 | docker run \ 81 | --entrypoint /build/scripts/test-django-mt-modules.sh \ 82 | -v $(pwd):/build \ 83 | --network django-multitenant_default \ 84 | -e PYTHON_VERSION -e DJANGO_VERSION \ 85 | -e DATABASE_HOST=django-multitenant_master \ 86 | -e DATABASE_PORT=5432 \ 87 | citusdata/python-all-versions 88 | - name: Upload coverage reports to Codecov 89 | uses: codecov/codecov-action@v3 90 | 91 | tests-django-mt-4_1: 92 | runs-on: ubuntu-latest 93 | name: "Python: ${{matrix.python_version}} | Django: ${{matrix.django_version}} | Citus: ${{matrix.citus_version}}" 94 | defaults: 95 | run: 96 | shell: bash 97 | strategy: 98 | fail-fast: false 99 | matrix: 100 | python_version: ["3.8","3.9","3.10","3.11"] 101 | django_version: ["4.1"] 102 | citus_version: ["11"] 103 | 104 | env: 105 | PYTHON_VERSION: ${{ matrix.python_version }} 106 | CITUS_VERSION: ${{ matrix.citus_version }} 107 | DJANGO_VERSION: ${{ matrix.django_version }} 108 | steps: 109 | - uses: actions/checkout@v3 110 | - name: Test Django MT 111 | run: | 112 | docker compose --project-name django-multitenant up -d || { docker compose logs && false ; } 113 | echo "Running tests for python $PYTHON_VERSION, django $DJANGO_VERSION, citus $CITUS_VERSION" 114 | docker run \ 115 | --entrypoint /build/scripts/test-django-mt.sh \ 116 | -v $(pwd):/build \ 117 | --network django-multitenant_default \ 118 | -e PYTHON_VERSION -e DJANGO_VERSION \ 119 | -e DATABASE_HOST=django-multitenant_master \ 120 | -e DATABASE_PORT=5432 \ 121 | citusdata/python-all-versions 122 | - name: Upload coverage reports to Codecov 123 | uses: codecov/codecov-action@v3 124 | - name: Test Missing Modules 125 | run: | 126 | echo "Running tests for python $PYTHON_VERSION, django $DJANGO_VERSION, citus $CITUS_VERSION" 127 | docker run \ 128 | --entrypoint /build/scripts/test-django-mt-modules.sh \ 129 | -v $(pwd):/build \ 130 | --network django-multitenant_default \ 131 | -e PYTHON_VERSION -e DJANGO_VERSION \ 132 | -e DATABASE_HOST=django-multitenant_master \ 133 | -e DATABASE_PORT=5432 \ 134 | citusdata/python-all-versions 135 | - name: Upload coverage reports to Codecov 136 | uses: codecov/codecov-action@v3 137 | -------------------------------------------------------------------------------- /django_multitenant/tests/base.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | 3 | from exam.cases import Exam 4 | from exam.decorators import fixture 5 | 6 | from .models import ( 7 | Country, 8 | Account, 9 | Project, 10 | Task, 11 | Revenue, 12 | ProjectManager, 13 | SubTask, 14 | Manager, 15 | SomeRelatedModel, 16 | TenantNotIdModel, 17 | Organization, 18 | ) 19 | 20 | 21 | class Fixtures(Exam): 22 | # @after 23 | # def print_after(self): 24 | # for q in connection.queries: 25 | # print(q['sql']) 26 | 27 | @fixture 28 | def india(self): 29 | return Country.objects.create(name="India") 30 | 31 | @fixture 32 | def france(self): 33 | return Country.objects.create(name="France") 34 | 35 | @fixture 36 | def united_states(self): 37 | return Country.objects.create(name="United States") 38 | 39 | @fixture 40 | def account_fr(self): 41 | return Account.objects.create( 42 | pk=1, 43 | name="Account FR", 44 | country=self.france, 45 | subdomain="fr.", 46 | domain="citusdata.com", 47 | ) 48 | 49 | @fixture 50 | def account_in(self): 51 | return Account.objects.create( 52 | pk=2, 53 | name="Account IN", 54 | country=self.india, 55 | subdomain="in.", 56 | domain="citusdata.com", 57 | ) 58 | 59 | @fixture 60 | def account_us(self): 61 | return Account.objects.create( 62 | pk=3, 63 | name="Account US", 64 | country=self.united_states, 65 | subdomain="us.", 66 | domain="citusdata.com", 67 | ) 68 | 69 | @fixture 70 | def accounts(self): 71 | return [self.account_fr, self.account_in, self.account_us] 72 | 73 | @fixture 74 | def projects(self): 75 | projects = [] 76 | 77 | for account in self.accounts: 78 | for i in range(10): 79 | projects.append( 80 | Project.objects.create(account_id=account.pk, name=f"project {i}") 81 | ) 82 | 83 | return projects 84 | 85 | @fixture 86 | def managers(self): 87 | managers = [] 88 | 89 | for account in self.accounts: 90 | for i in range(5): 91 | managers.append( 92 | Manager.objects.create(name=f"manager {i}", account=account) 93 | ) 94 | 95 | return managers 96 | 97 | @fixture 98 | def tasks(self): 99 | tasks = [] 100 | 101 | for project in self.projects: 102 | previous_task = None 103 | for i in range(5): 104 | previous_task = Task.objects.create( 105 | name=f"task project {project.name} {i}", 106 | project_id=project.pk, 107 | account_id=project.account_id, 108 | parent=previous_task, 109 | ) 110 | 111 | tasks.append(previous_task) 112 | 113 | return tasks 114 | 115 | @fixture 116 | def revenues(self): 117 | revenues = [] 118 | 119 | for project in self.projects: 120 | for i in range(5): 121 | revenue = Revenue.objects.create( 122 | value=f"{i} mil", 123 | project_id=project.pk, 124 | acc_id=project.account_id, 125 | ) 126 | revenues.append(revenue) 127 | 128 | return revenues 129 | 130 | @fixture 131 | def project_managers(self): 132 | projects = self.projects 133 | managers = self.managers 134 | project_managers = [] 135 | 136 | for project in projects: 137 | for manager in project.account.managers.all(): 138 | project_managers.append( 139 | ProjectManager.objects.create( 140 | account=project.account, project=project, manager=manager 141 | ) 142 | ) 143 | return project_managers 144 | 145 | @fixture 146 | def subtasks(self): 147 | subtasks = [] 148 | 149 | for task in self.tasks: 150 | for i in range(5): 151 | subtasks.append( 152 | SubTask.objects.create( 153 | name=f"subtask project {i}, task {i}", 154 | type="test", 155 | account_id=task.account_id, 156 | project_id=task.project_id, 157 | task=task, 158 | ) 159 | ) 160 | 161 | return subtasks 162 | 163 | @fixture 164 | def organization(self): 165 | return Organization.objects.create(name="organization") 166 | 167 | @fixture 168 | def tenant_not_id(self): 169 | tenants = [] 170 | for i in range(3): 171 | tenant = TenantNotIdModel(tenant_column=i + 1, name=f"test {i}") 172 | tenant.save() 173 | 174 | tenants.append(tenant) 175 | 176 | for j in range(10): 177 | SomeRelatedModel.objects.create( 178 | related_tenant=tenant, name=f"related {j}" 179 | ) 180 | return tenants 181 | 182 | 183 | class BaseTestCase(Fixtures, TransactionTestCase): 184 | pass 185 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0028_migrationuseinmigrationsmodel_alter_account_managers_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-18 11:03 2 | 3 | from django.db import migrations, models 4 | import django_multitenant.fields 5 | import django_multitenant.mixins 6 | import django_multitenant.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("tests", "0027_many_to_many_distribute"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="MigrationUseInMigrationsModel", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ], 29 | options={ 30 | "abstract": False, 31 | }, 32 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 33 | managers=[ 34 | ("objects", django_multitenant.models.TenantManager()), 35 | ], 36 | ), 37 | migrations.AlterModelManagers( 38 | name="account", 39 | managers=[ 40 | ("objects", django_multitenant.models.TenantManager()), 41 | ], 42 | ), 43 | migrations.AlterModelManagers( 44 | name="aliasedtask", 45 | managers=[ 46 | ("objects", django_multitenant.models.TenantManager()), 47 | ], 48 | ), 49 | migrations.AlterModelManagers( 50 | name="business", 51 | managers=[ 52 | ("objects", django_multitenant.models.TenantManager()), 53 | ], 54 | ), 55 | migrations.AlterModelManagers( 56 | name="manager", 57 | managers=[ 58 | ("objects", django_multitenant.models.TenantManager()), 59 | ], 60 | ), 61 | migrations.AlterModelManagers( 62 | name="migrationtestmodel", 63 | managers=[ 64 | ("objects", django_multitenant.models.TenantManager()), 65 | ], 66 | ), 67 | migrations.AlterModelManagers( 68 | name="modelconfig", 69 | managers=[ 70 | ("objects", django_multitenant.models.TenantManager()), 71 | ], 72 | ), 73 | migrations.AlterModelManagers( 74 | name="organization", 75 | managers=[ 76 | ("objects", django_multitenant.models.TenantManager()), 77 | ], 78 | ), 79 | migrations.AlterModelManagers( 80 | name="product", 81 | managers=[ 82 | ("objects", django_multitenant.models.TenantManager()), 83 | ], 84 | ), 85 | migrations.AlterModelManagers( 86 | name="project", 87 | managers=[ 88 | ("objects", django_multitenant.models.TenantManager()), 89 | ], 90 | ), 91 | migrations.AlterModelManagers( 92 | name="projectmanager", 93 | managers=[ 94 | ("objects", django_multitenant.models.TenantManager()), 95 | ], 96 | ), 97 | migrations.AlterModelManagers( 98 | name="purchase", 99 | managers=[ 100 | ("objects", django_multitenant.models.TenantManager()), 101 | ], 102 | ), 103 | migrations.AlterModelManagers( 104 | name="record", 105 | managers=[ 106 | ("objects", django_multitenant.models.TenantManager()), 107 | ], 108 | ), 109 | migrations.AlterModelManagers( 110 | name="revenue", 111 | managers=[ 112 | ("objects", django_multitenant.models.TenantManager()), 113 | ], 114 | ), 115 | migrations.AlterModelManagers( 116 | name="somerelatedmodel", 117 | managers=[ 118 | ("objects", django_multitenant.models.TenantManager()), 119 | ], 120 | ), 121 | migrations.AlterModelManagers( 122 | name="store", 123 | managers=[ 124 | ("objects", django_multitenant.models.TenantManager()), 125 | ], 126 | ), 127 | migrations.AlterModelManagers( 128 | name="subtask", 129 | managers=[ 130 | ("objects", django_multitenant.models.TenantManager()), 131 | ], 132 | ), 133 | migrations.AlterModelManagers( 134 | name="template", 135 | managers=[ 136 | ("objects", django_multitenant.models.TenantManager()), 137 | ], 138 | ), 139 | migrations.AlterModelManagers( 140 | name="tenant", 141 | managers=[ 142 | ("objects", django_multitenant.models.TenantManager()), 143 | ], 144 | ), 145 | migrations.AlterModelManagers( 146 | name="tenantnotidmodel", 147 | managers=[ 148 | ("objects", django_multitenant.models.TenantManager()), 149 | ], 150 | ), 151 | migrations.AlterModelManagers( 152 | name="transaction", 153 | managers=[ 154 | ("objects", django_multitenant.models.TenantManager()), 155 | ], 156 | ), 157 | ] 158 | -------------------------------------------------------------------------------- /django_multitenant/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import importlib 3 | from asgiref.sync import async_to_sync 4 | 5 | 6 | from django_multitenant.utils import ( 7 | set_current_tenant, 8 | get_current_tenant, 9 | unset_current_tenant, 10 | get_tenant_column, 11 | get_current_tenant_value, 12 | get_tenant_filters, 13 | ) 14 | 15 | from .base import BaseTestCase 16 | 17 | 18 | class UtilsTest(BaseTestCase): 19 | async def async_get_current_tenant(self): 20 | return get_current_tenant() 21 | 22 | async def async_set_current_tenant(self, tenant): 23 | return set_current_tenant(tenant) 24 | 25 | def test_set_current_tenant(self): 26 | projects = self.projects 27 | account = projects[0].account 28 | 29 | set_current_tenant(account) 30 | self.assertEqual(get_current_tenant(), account) 31 | unset_current_tenant() 32 | 33 | def test_tenant_persists_from_thread_to_async_task(self): 34 | projects = self.projects 35 | account = projects[0].account 36 | 37 | # Set the tenant in main thread 38 | set_current_tenant(account) 39 | 40 | with self.settings(TENANT_USE_ASGIREF=True): 41 | importlib.reload(sys.modules["django_multitenant.utils"]) 42 | 43 | # Check the tenant within an async task when asgiref enabled 44 | tenant = async_to_sync(self.async_get_current_tenant)() 45 | self.assertEqual(get_current_tenant(), tenant) 46 | unset_current_tenant() 47 | 48 | with self.settings(TENANT_USE_ASGIREF=False): 49 | importlib.reload(sys.modules["django_multitenant.utils"]) 50 | 51 | # Check the tenant within an async task when asgiref is disabled 52 | tenant = async_to_sync(self.async_get_current_tenant)() 53 | self.assertIsNone(get_current_tenant()) 54 | unset_current_tenant() 55 | 56 | def test_tenant_persists_from_async_task_to_thread(self): 57 | projects = self.projects 58 | account = projects[0].account 59 | 60 | with self.settings(TENANT_USE_ASGIREF=True): 61 | importlib.reload(sys.modules["django_multitenant.settings"]) 62 | importlib.reload(sys.modules["django_multitenant.utils"]) 63 | 64 | # Set the tenant in task 65 | async_to_sync(self.async_set_current_tenant)(account) 66 | self.assertEqual(get_current_tenant(), account) 67 | unset_current_tenant() 68 | 69 | with self.settings(TENANT_USE_ASGIREF=False): 70 | importlib.reload(sys.modules["django_multitenant.settings"]) 71 | importlib.reload(sys.modules["django_multitenant.utils"]) 72 | 73 | # Set the tenant in task 74 | async_to_sync(self.async_set_current_tenant)(account) 75 | self.assertIsNone(get_current_tenant()) 76 | unset_current_tenant() 77 | 78 | def test_get_tenant_column(self): 79 | from .models import Project 80 | 81 | projects = self.projects 82 | account = projects[0].account 83 | 84 | # test if instance 85 | column = get_tenant_column(account) 86 | self.assertEqual(column, "id") 87 | self.assertEqual(get_tenant_column(account), account.tenant_field) 88 | 89 | # test if model 90 | column = get_tenant_column(Project) 91 | self.assertEqual(column, "account_id") 92 | 93 | def test_current_tenant_value_single(self): 94 | projects = self.projects 95 | account = projects[0].account 96 | set_current_tenant(account) 97 | 98 | self.assertEqual(get_current_tenant_value(), account.id) 99 | 100 | unset_current_tenant() 101 | 102 | def test_current_tenant_value_list(self): 103 | projects = self.projects 104 | accounts = [projects[0].account, projects[1].account] 105 | set_current_tenant(accounts) 106 | 107 | value = get_current_tenant_value() 108 | 109 | self.assertTrue(isinstance(value, list)) 110 | self.assertEqual(value, [accounts[0].id, accounts[1].id]) 111 | 112 | unset_current_tenant() 113 | 114 | def test_tenant_filters_single_tenant(self): 115 | from .models import Project 116 | 117 | projects = self.projects 118 | account = projects[0].account 119 | set_current_tenant(account) 120 | 121 | self.assertEqual(get_tenant_filters(Project), {"account_id": account.pk}) 122 | 123 | unset_current_tenant() 124 | 125 | def test_tenant_filters_multi_tenant(self): 126 | from .models import Project 127 | 128 | projects = self.projects 129 | accounts = [projects[0].account, projects[1].account] 130 | set_current_tenant(accounts) 131 | 132 | self.assertEqual( 133 | get_tenant_filters(Project), 134 | {"account_id__in": [accounts[0].id, accounts[1].id]}, 135 | ) 136 | 137 | unset_current_tenant() 138 | 139 | def test_current_tenant_value_queryset_value(self): 140 | from .models import Account 141 | 142 | projects = self.projects 143 | accounts = Account.objects.all().order_by("id") 144 | set_current_tenant(accounts) 145 | 146 | value = get_current_tenant_value() 147 | 148 | self.assertTrue(isinstance(value, list)) 149 | self.assertEqual(value, list(accounts.values_list("id", flat=True))) 150 | 151 | unset_current_tenant() 152 | 153 | def test_current_tenant_value_queryset_filter(self): 154 | from .models import Project, Account 155 | 156 | projects = self.projects 157 | accounts = Account.objects.all().order_by("id") 158 | set_current_tenant(accounts) 159 | 160 | value = get_current_tenant_value() 161 | 162 | self.assertEqual( 163 | get_tenant_filters(Project), 164 | {"account_id__in": list(accounts.values_list("id", flat=True))}, 165 | ) 166 | unset_current_tenant() 167 | -------------------------------------------------------------------------------- /django_multitenant/backends/postgresql/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import django 3 | from django.db.backends.postgresql.base import ( 4 | DatabaseFeatures as PostgresqlDatabaseFeatures, 5 | DatabaseWrapper as PostgresqlDatabaseWrapper, 6 | DatabaseSchemaEditor as PostgresqlDatabaseSchemaEditor, 7 | ) 8 | from django_multitenant.fields import TenantForeignKey 9 | from django_multitenant.utils import get_model_by_db_table, get_tenant_column 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class DatabaseSchemaEditor(PostgresqlDatabaseSchemaEditor): 15 | sql_create_column_inline_fk = None 16 | 17 | # Override 18 | def __enter__(self): 19 | ret = super().__enter__() 20 | return ret 21 | 22 | # pylint: disable=too-many-arguments 23 | def _alter_field( 24 | self, 25 | model, 26 | old_field, 27 | new_field, 28 | old_type, 29 | new_type, 30 | old_db_params, 31 | new_db_params, 32 | strict=False, 33 | ): 34 | """ 35 | If there is a change in the field, this method assures that if the field is type of TenantForeignKey 36 | and db_constraint does not exist, adds the foreign key constraint. 37 | """ 38 | 39 | super()._alter_field( 40 | model, 41 | old_field, 42 | new_field, 43 | old_type, 44 | new_type, 45 | old_db_params, 46 | new_db_params, 47 | strict, 48 | ) 49 | 50 | # If the pkey was dropped in the previous distribute migration, 51 | # foreign key constraints didn't previously exists so django does not 52 | # recreated them. 53 | # Here we test if we are in this case 54 | if isinstance(new_field, TenantForeignKey) and new_field.db_constraint: 55 | from_model = get_model_by_db_table(model._meta.db_table) 56 | fk_names = self._constraint_names( 57 | model, [new_field.column], foreign_key=True 58 | ) + self._constraint_names( 59 | model, 60 | [new_field.column, get_tenant_column(from_model)], 61 | foreign_key=True, 62 | ) 63 | if not fk_names: 64 | self.execute( 65 | self._create_fk_sql( 66 | model, new_field, "_fk_%(to_table)s_%(to_column)s" 67 | ) 68 | ) 69 | 70 | # Override 71 | def _create_fk_sql(self, model, field, suffix): 72 | """ 73 | This method overrides the additions foreign key constraint sql and adds the tenant column to the constraint 74 | """ 75 | if isinstance(field, TenantForeignKey): 76 | try: 77 | # test if both models exists 78 | # This case happens when we are running from scratch migrations and one model was removed from code 79 | # In the previous migrations we would still be creating the foreign key 80 | from_model = get_model_by_db_table(model._meta.db_table) 81 | to_model = get_model_by_db_table( 82 | field.target_field.model._meta.db_table 83 | ) 84 | except ValueError: 85 | return None 86 | 87 | from_columns = field.column, get_tenant_column(from_model) 88 | to_columns = field.target_field.column, get_tenant_column(to_model) 89 | suffix = suffix % { 90 | "to_table": field.target_field.model._meta.db_table, 91 | "to_column": "_".join(to_columns), 92 | } 93 | 94 | return self.sql_create_fk % { 95 | "table": self.quote_name(model._meta.db_table), 96 | "name": self.quote_name( 97 | self._create_index_name( 98 | model._meta.db_table, from_columns, suffix=suffix 99 | ) 100 | ), 101 | "column": ", ".join( 102 | [self.quote_name(from_col) for from_col in from_columns] 103 | ), 104 | "to_table": self.quote_name(field.target_field.model._meta.db_table), 105 | "to_column": ", ".join( 106 | [self.quote_name(to_col) for to_col in to_columns] 107 | ), 108 | "deferrable": self.connection.ops.deferrable_sql(), 109 | } 110 | return super()._create_fk_sql(model, field, suffix) 111 | 112 | # Override 113 | def execute(self, sql, params=()): 114 | # Hack: Citus will throw the following error if these statements are 115 | # not executed separately: "ERROR: cannot execute multiple utility events" 116 | if sql and not params: 117 | for statement in str(sql).split(";"): 118 | if statement and not statement.isspace(): 119 | super().execute(statement) 120 | elif sql: 121 | super().execute(sql, params) 122 | 123 | 124 | # noqa 125 | class TenantDatabaseFeatures(PostgresqlDatabaseFeatures): 126 | # The default Django behaviour is to collapse the fields to just the 'id' 127 | # field. This doesn't work because we're using a composite primary key. In 128 | # Django version 3.0 a function was added that we can override to specify 129 | # for specific models that this behaviour should be disabled. 130 | def allows_group_by_selected_pks_on_model(self, model): 131 | # pylint: disable=import-outside-toplevel 132 | from django_multitenant.models import TenantModel 133 | 134 | if issubclass(model, TenantModel): 135 | return False 136 | return super().allows_group_by_selected_pks_on_model(model) 137 | 138 | # For django versions before version 3.0 we set a flag that disables this 139 | # behaviour for all models. 140 | if django.VERSION < (3, 0): 141 | allows_group_by_selected_pks = False 142 | 143 | 144 | class DatabaseWrapper(PostgresqlDatabaseWrapper): 145 | # Override 146 | SchemaEditorClass = DatabaseSchemaEditor 147 | features_class = TenantDatabaseFeatures 148 | -------------------------------------------------------------------------------- /requirements/static-analysis-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/static-analysis-requirements.txt --resolver=backtracking requirements/static-analysis.in 6 | # 7 | alabaster==0.7.13 8 | # via sphinx 9 | asgiref==3.7.2 10 | # via django 11 | astroid==2.13.5 12 | # via 13 | # pylint 14 | # pylint-celery 15 | # pylint-flask 16 | # requirements-detector 17 | babel==2.12.1 18 | # via sphinx 19 | backports-zoneinfo==0.2.1 20 | # via django 21 | bandit==1.7.5 22 | # via prospector 23 | black==24.3.0 24 | # via -r requirements/static-analysis.in 25 | build==1.0.3 26 | # via pyroma 27 | certifi==2024.7.4 28 | # via requests 29 | charset-normalizer==3.2.0 30 | # via requests 31 | click==8.1.7 32 | # via black 33 | coverage[toml]==7.3.1 34 | # via pytest-cov 35 | dill==0.3.7 36 | # via pylint 37 | django==4.2.27 38 | # via 39 | # -r requirements/static-analysis.in 40 | # djangorestframework 41 | djangorestframework==3.15.2 42 | # via -r requirements/static-analysis.in 43 | docutils==0.18.1 44 | # via 45 | # pyroma 46 | # sphinx 47 | # sphinx-rtd-theme 48 | dodgy==0.2.1 49 | # via prospector 50 | exam==0.10.6 51 | # via -r requirements/static-analysis.in 52 | exceptiongroup==1.1.3 53 | # via pytest 54 | flake8==5.0.4 55 | # via 56 | # flake8-polyfill 57 | # prospector 58 | flake8-polyfill==1.0.2 59 | # via pep8-naming 60 | gitdb==4.0.10 61 | # via gitpython 62 | gitpython==3.1.37 63 | # via 64 | # bandit 65 | # prospector 66 | idna==3.7 67 | # via requests 68 | imagesize==1.4.1 69 | # via sphinx 70 | importlib-metadata==6.8.0 71 | # via 72 | # build 73 | # sphinx 74 | iniconfig==2.0.0 75 | # via pytest 76 | isort==5.12.0 77 | # via pylint 78 | jinja2==3.1.6 79 | # via sphinx 80 | lazy-object-proxy==1.9.0 81 | # via astroid 82 | markdown-it-py==3.0.0 83 | # via rich 84 | markupsafe==2.1.3 85 | # via jinja2 86 | mccabe==0.7.0 87 | # via 88 | # flake8 89 | # prospector 90 | # pylint 91 | mdurl==0.1.2 92 | # via markdown-it-py 93 | mock==5.1.0 94 | # via exam 95 | mypy==1.5.1 96 | # via prospector 97 | mypy-extensions==1.0.0 98 | # via 99 | # black 100 | # mypy 101 | nodeenv==1.8.0 102 | # via pyright 103 | packaging==23.1 104 | # via 105 | # black 106 | # build 107 | # prospector 108 | # pyroma 109 | # pytest 110 | # requirements-detector 111 | # sphinx 112 | pathspec==0.11.2 113 | # via black 114 | pbr==5.11.1 115 | # via stevedore 116 | pep8-naming==0.10.0 117 | # via prospector 118 | platformdirs==3.10.0 119 | # via 120 | # black 121 | # pylint 122 | pluggy==1.3.0 123 | # via pytest 124 | prospector[with_everything]==1.10.2 125 | # via -r requirements/static-analysis.in 126 | psycopg2-binary==2.9.7 127 | # via -r requirements/static-analysis.in 128 | pycodestyle==2.9.1 129 | # via 130 | # flake8 131 | # prospector 132 | pydocstyle==6.3.0 133 | # via prospector 134 | pyflakes==2.5.0 135 | # via 136 | # flake8 137 | # prospector 138 | pygments==2.16.1 139 | # via 140 | # pyroma 141 | # rich 142 | # sphinx 143 | pylint==2.15.10 144 | # via 145 | # -r requirements/static-analysis.in 146 | # prospector 147 | # pylint-celery 148 | # pylint-django 149 | # pylint-flask 150 | # pylint-plugin-utils 151 | pylint-celery==0.3 152 | # via prospector 153 | pylint-django==2.5.3 154 | # via prospector 155 | pylint-flask==0.6 156 | # via prospector 157 | pylint-plugin-utils==0.7 158 | # via 159 | # prospector 160 | # pylint-celery 161 | # pylint-django 162 | # pylint-flask 163 | pyproject-hooks==1.0.0 164 | # via build 165 | pyright==1.1.327 166 | # via prospector 167 | pyroma==4.2 168 | # via prospector 169 | pytest==7.4.2 170 | # via 171 | # -r requirements/static-analysis.in 172 | # pytest-cov 173 | # pytest-django 174 | pytest-cov==4.1.0 175 | # via -r requirements/static-analysis.in 176 | pytest-django==4.5.2 177 | # via -r requirements/static-analysis.in 178 | pytz==2023.3.post1 179 | # via 180 | # babel 181 | # djangorestframework 182 | pyyaml==6.0.1 183 | # via 184 | # bandit 185 | # prospector 186 | requests==2.32.4 187 | # via 188 | # pyroma 189 | # sphinx 190 | requirements-detector==1.2.2 191 | # via prospector 192 | rich==13.5.3 193 | # via bandit 194 | semver==3.0.1 195 | # via requirements-detector 196 | setoptconf-tmp==0.3.1 197 | # via prospector 198 | smmap==5.0.1 199 | # via gitdb 200 | snowballstemmer==2.2.0 201 | # via 202 | # pydocstyle 203 | # sphinx 204 | sphinx==7.1.2 205 | # via 206 | # -r requirements/static-analysis.in 207 | # sphinx-rtd-theme 208 | # sphinxcontrib-jquery 209 | # sphinxnotes-strike 210 | sphinx-rtd-theme==1.3.0 211 | # via -r requirements/static-analysis.in 212 | sphinxcontrib-applehelp==1.0.4 213 | # via sphinx 214 | sphinxcontrib-devhelp==1.0.2 215 | # via sphinx 216 | sphinxcontrib-htmlhelp==2.0.1 217 | # via sphinx 218 | sphinxcontrib-jquery==4.1 219 | # via sphinx-rtd-theme 220 | sphinxcontrib-jsmath==1.0.1 221 | # via sphinx 222 | sphinxcontrib-qthelp==1.0.3 223 | # via sphinx 224 | sphinxcontrib-serializinghtml==1.1.5 225 | # via sphinx 226 | sphinxnotes-strike==1.2 227 | # via -r requirements/static-analysis.in 228 | sqlparse==0.5.0 229 | # via django 230 | stevedore==5.1.0 231 | # via bandit 232 | toml==0.10.2 233 | # via 234 | # prospector 235 | # requirements-detector 236 | # vulture 237 | tomli==2.0.1 238 | # via 239 | # black 240 | # build 241 | # coverage 242 | # mypy 243 | # pylint 244 | # pyproject-hooks 245 | # pytest 246 | tomlkit==0.12.1 247 | # via pylint 248 | trove-classifiers==2023.9.19 249 | # via pyroma 250 | typing-extensions==4.8.0 251 | # via 252 | # asgiref 253 | # astroid 254 | # black 255 | # mypy 256 | # pylint 257 | # rich 258 | urllib3==2.6.0 259 | # via requests 260 | vulture==2.9.1 261 | # via prospector 262 | wrapt==1.15.0 263 | # via astroid 264 | zipp==3.19.1 265 | # via importlib-metadata 266 | 267 | # The following packages are considered to be unsafe in a requirements file: 268 | # setuptools 269 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0026_product_purchase_store_alter_account_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2023-02-20 17:41 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django_multitenant.fields 6 | import django_multitenant.mixins 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("tests", "0025_data_load"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Product", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ("description", models.TextField()), 29 | ], 30 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 31 | ), 32 | migrations.CreateModel( 33 | name="Purchase", 34 | fields=[ 35 | ( 36 | "id", 37 | models.BigAutoField( 38 | auto_created=True, 39 | primary_key=True, 40 | serialize=False, 41 | verbose_name="ID", 42 | ), 43 | ), 44 | ], 45 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 46 | ), 47 | migrations.CreateModel( 48 | name="Staff", 49 | fields=[ 50 | ( 51 | "id", 52 | models.BigAutoField( 53 | auto_created=True, 54 | primary_key=True, 55 | serialize=False, 56 | verbose_name="ID", 57 | ), 58 | ), 59 | ("name", models.CharField(max_length=50)), 60 | ], 61 | ), 62 | migrations.CreateModel( 63 | name="Store", 64 | fields=[ 65 | ( 66 | "id", 67 | models.BigAutoField( 68 | auto_created=True, 69 | primary_key=True, 70 | serialize=False, 71 | verbose_name="ID", 72 | ), 73 | ), 74 | ("name", models.CharField(max_length=50)), 75 | ("address", models.CharField(max_length=255)), 76 | ("email", models.CharField(max_length=50)), 77 | ], 78 | options={ 79 | "abstract": False, 80 | }, 81 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 82 | ), 83 | migrations.CreateModel( 84 | name="StoreStaff", 85 | fields=[ 86 | ( 87 | "id", 88 | models.BigAutoField( 89 | auto_created=True, 90 | primary_key=True, 91 | serialize=False, 92 | verbose_name="ID", 93 | ), 94 | ), 95 | ( 96 | "staff", 97 | models.ForeignKey( 98 | on_delete=django.db.models.deletion.CASCADE, to="tests.staff" 99 | ), 100 | ), 101 | ( 102 | "store", 103 | models.ForeignKey( 104 | on_delete=django.db.models.deletion.CASCADE, to="tests.store" 105 | ), 106 | ), 107 | ], 108 | options={ 109 | "abstract": False, 110 | }, 111 | ), 112 | migrations.AddField( 113 | model_name="store", 114 | name="store_staffs", 115 | field=models.ManyToManyField( 116 | blank=True, through="tests.StoreStaff", to="tests.Staff" 117 | ), 118 | ), 119 | migrations.CreateModel( 120 | name="Transaction", 121 | fields=[ 122 | ( 123 | "id", 124 | models.BigAutoField( 125 | auto_created=True, 126 | primary_key=True, 127 | serialize=False, 128 | verbose_name="ID", 129 | ), 130 | ), 131 | ("date", models.DateField()), 132 | ( 133 | "product", 134 | django_multitenant.fields.TenantForeignKey( 135 | on_delete=django.db.models.deletion.CASCADE, to="tests.product" 136 | ), 137 | ), 138 | ( 139 | "purchase", 140 | django_multitenant.fields.TenantForeignKey( 141 | blank=True, 142 | null=True, 143 | on_delete=django.db.models.deletion.CASCADE, 144 | to="tests.purchase", 145 | ), 146 | ), 147 | ( 148 | "store", 149 | models.ForeignKey( 150 | on_delete=django.db.models.deletion.CASCADE, to="tests.store" 151 | ), 152 | ), 153 | ], 154 | options={ 155 | "abstract": False, 156 | }, 157 | bases=(django_multitenant.mixins.TenantModelMixin, models.Model), 158 | ), 159 | migrations.AddField( 160 | model_name="purchase", 161 | name="product_purchased", 162 | field=models.ManyToManyField( 163 | through="tests.Transaction", to="tests.Product" 164 | ), 165 | ), 166 | migrations.AddField( 167 | model_name="purchase", 168 | name="store", 169 | field=models.ForeignKey( 170 | on_delete=django.db.models.deletion.CASCADE, to="tests.store" 171 | ), 172 | ), 173 | migrations.AddField( 174 | model_name="product", 175 | name="store", 176 | field=models.ForeignKey( 177 | on_delete=django.db.models.deletion.CASCADE, to="tests.store" 178 | ), 179 | ), 180 | migrations.AlterUniqueTogether( 181 | name="purchase", 182 | unique_together={("id", "store")}, 183 | ), 184 | migrations.AlterUniqueTogether( 185 | name="product", 186 | unique_together={("id", "store")}, 187 | ), 188 | ] 189 | -------------------------------------------------------------------------------- /django_multitenant/tests/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | from django.contrib.auth import get_user_model 6 | 7 | from django_multitenant.mixins import TenantModelMixin, TenantManagerMixin 8 | from django_multitenant.models import TenantModel 9 | from django_multitenant.fields import TenantForeignKey 10 | 11 | 12 | class Country(models.Model): 13 | name = models.CharField(max_length=255) 14 | 15 | 16 | class Account(TenantModel): 17 | name = models.CharField(max_length=255) 18 | domain = models.CharField(max_length=255) 19 | subdomain = models.CharField(max_length=255) 20 | country = models.ForeignKey(Country, on_delete=models.CASCADE) 21 | employee = models.ForeignKey( 22 | "Employee", 23 | on_delete=models.CASCADE, 24 | related_name="accounts", 25 | null=True, 26 | blank=True, 27 | ) 28 | 29 | # TODO change to Meta 30 | tenant_id = "id" 31 | 32 | 33 | class Employee(models.Model): 34 | # Reference table 35 | account = models.ForeignKey( 36 | Account, 37 | on_delete=models.CASCADE, 38 | null=True, 39 | blank=True, 40 | related_name="employees", 41 | ) 42 | created_by = models.ForeignKey( 43 | "self", 44 | blank=True, 45 | null=True, 46 | related_name="users_created", 47 | on_delete=models.SET_NULL, 48 | ) 49 | 50 | name = models.CharField(max_length=255) 51 | 52 | 53 | class ModelConfig(TenantModel): 54 | name = models.CharField(max_length=255) 55 | account = models.ForeignKey( 56 | Account, on_delete=models.CASCADE, related_name="configs" 57 | ) 58 | employee = models.ForeignKey( 59 | Employee, 60 | on_delete=models.CASCADE, 61 | related_name="configs", 62 | null=True, 63 | blank=True, 64 | ) 65 | 66 | tenant_id = "account_id" 67 | 68 | 69 | class Manager(TenantModel): 70 | name = models.CharField(max_length=255) 71 | account = models.ForeignKey( 72 | Account, on_delete=models.CASCADE, related_name="managers" 73 | ) 74 | tenant_id = "account_id" 75 | 76 | 77 | class Project(TenantModel): 78 | name = models.CharField(max_length=255) 79 | account = models.ForeignKey( 80 | Account, related_name="projects", on_delete=models.CASCADE 81 | ) 82 | managers = models.ManyToManyField(Manager, through="ProjectManager") 83 | employee = models.ForeignKey( 84 | Employee, 85 | related_name="projects", 86 | on_delete=models.SET_NULL, 87 | null=True, 88 | blank=True, 89 | ) 90 | tenant_id = "account_id" 91 | 92 | 93 | class ProjectManager(TenantModel): 94 | project = TenantForeignKey( 95 | Project, on_delete=models.CASCADE, related_name="projectmanagers" 96 | ) 97 | manager = TenantForeignKey(Manager, on_delete=models.CASCADE) 98 | account = models.ForeignKey(Account, on_delete=models.CASCADE) 99 | 100 | tenant_id = "account_id" 101 | 102 | 103 | class TaskQueryset(models.QuerySet): 104 | def opened(self): 105 | return self.filter(opened=True) 106 | 107 | def closed(self): 108 | return self.filter(opened=False) 109 | 110 | 111 | class TaskManager(TenantManagerMixin, models.Manager): 112 | _queryset_class = TaskQueryset 113 | 114 | def opened(self): 115 | return self.get_queryset().opened() 116 | 117 | def closed(self): 118 | return self.get_queryset().closed() 119 | 120 | 121 | class Task(TenantModelMixin, models.Model): 122 | name = models.CharField(max_length=255) 123 | project = TenantForeignKey(Project, on_delete=models.CASCADE, related_name="tasks") 124 | account = models.ForeignKey(Account, on_delete=models.CASCADE) 125 | opened = models.BooleanField(default=True) 126 | 127 | parent = TenantForeignKey( 128 | "self", on_delete=models.CASCADE, db_index=False, blank=True, null=True 129 | ) 130 | 131 | objects = TaskManager() 132 | 133 | tenant_id = "account_id" 134 | 135 | 136 | class SubTask(TenantModel): 137 | name = models.CharField(max_length=255) 138 | type = models.CharField(max_length=255) 139 | account = models.ForeignKey(Account, on_delete=models.CASCADE) 140 | task = TenantForeignKey(Task, on_delete=models.CASCADE) 141 | project = TenantForeignKey(Project, on_delete=models.CASCADE, null=True) 142 | 143 | tenant_id = "account_id" 144 | 145 | 146 | class UnscopedModel(models.Model): 147 | name = models.CharField(max_length=255) 148 | 149 | 150 | class AliasedTask(TenantModel): 151 | project_alias = TenantForeignKey(Project, on_delete=models.CASCADE) 152 | account = models.ForeignKey(Account, on_delete=models.CASCADE) 153 | 154 | tenant_id = "account_id" 155 | 156 | 157 | class Revenue(TenantModel): 158 | # To test for correct tenant_id push down in query 159 | acc = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="revenues") 160 | project = TenantForeignKey( 161 | Project, on_delete=models.CASCADE, related_name="revenues" 162 | ) 163 | value = models.CharField(max_length=30) 164 | 165 | tenant_id = "acc_id" 166 | 167 | 168 | # Models for UUID tests 169 | class Organization(TenantModel): 170 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 171 | name = models.CharField(max_length=255) 172 | 173 | class TenantMeta: 174 | tenant_field_name = "id" 175 | 176 | 177 | class Record(TenantModel): 178 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 179 | name = models.CharField(max_length=255) 180 | organization = TenantForeignKey(Organization, on_delete=models.CASCADE) 181 | 182 | class TenantMeta: 183 | tenant_id = "organization_id" 184 | 185 | 186 | class TenantNotIdModel(TenantModel): 187 | tenant_column = models.IntegerField(primary_key=True, editable=False) 188 | name = models.CharField(max_length=255) 189 | 190 | tenant_id = "tenant_column" 191 | 192 | 193 | class SomeRelatedModel(TenantModel): 194 | related_tenant = models.ForeignKey(TenantNotIdModel, on_delete=models.CASCADE) 195 | name = models.CharField(max_length=255) 196 | 197 | tenant_id = "related_tenant_id" 198 | 199 | 200 | class MigrationTestModel(TenantModel): 201 | name = models.CharField(max_length=255) 202 | tenant_id = "id" 203 | 204 | 205 | class MigrationTestReferenceModel(models.Model): 206 | name = models.CharField(max_length=255) 207 | 208 | 209 | class Tenant(TenantModel): 210 | tenant_id = "id" 211 | name = models.CharField("tenant name", max_length=100) 212 | 213 | 214 | class Business(TenantModel): 215 | tenant = models.ForeignKey(Tenant, blank=True, null=True, on_delete=models.SET_NULL) 216 | bk_biz_id = models.IntegerField("business ID") 217 | bk_biz_name = models.CharField("business name", max_length=100) 218 | 219 | class Meta: 220 | constraints = [ 221 | models.UniqueConstraint( 222 | fields=["id", "tenant_id"], name="unique_business_tenant" 223 | ) 224 | ] 225 | 226 | class TenantMeta: 227 | tenant_field_name = "tenant_id" 228 | 229 | 230 | class Template(TenantModel): 231 | tenant = models.ForeignKey(Tenant, blank=True, null=True, on_delete=models.SET_NULL) 232 | business = TenantForeignKey( 233 | Business, blank=True, null=True, on_delete=models.SET_NULL 234 | ) 235 | name = models.CharField("name", max_length=100) 236 | 237 | class TenantMeta: 238 | tenant_field_name = "tenant_id" 239 | 240 | 241 | # Non-Tenant Model which should be Reference Table 242 | # to be referenced by Store which is a Tenant Model 243 | # in Citus 10. 244 | class Staff(models.Model): 245 | name = models.CharField(max_length=50) 246 | 247 | 248 | class Store(TenantModel): 249 | tenant_id = "id" 250 | name = models.CharField(max_length=50) 251 | address = models.CharField(max_length=255) 252 | email = models.CharField(max_length=50) 253 | user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, null=True) 254 | 255 | store_staffs = models.ManyToManyField( 256 | Staff, through="StoreStaff", through_fields=("store", "staff"), blank=True 257 | ) 258 | 259 | 260 | class StoreStaff(models.Model): 261 | store = models.ForeignKey(Store, on_delete=models.CASCADE) 262 | staff = models.ForeignKey(Staff, on_delete=models.CASCADE) 263 | 264 | 265 | class Product(TenantModel): 266 | store = models.ForeignKey(Store, on_delete=models.CASCADE) 267 | tenant_id = "store_id" 268 | name = models.CharField(max_length=255) 269 | description = models.TextField() 270 | 271 | class Meta: 272 | unique_together = ["id", "store"] 273 | 274 | 275 | class Purchase(TenantModel): 276 | store = models.ForeignKey(Store, on_delete=models.CASCADE) 277 | tenant_id = "store_id" 278 | product_purchased = models.ManyToManyField( 279 | Product, through="Transaction", through_fields=("purchase", "product") 280 | ) 281 | 282 | class Meta: 283 | unique_together = ["id", "store"] 284 | 285 | 286 | class Transaction(TenantModel): 287 | store = models.ForeignKey(Store, on_delete=models.CASCADE) 288 | tenant_id = "store_id" 289 | purchase = TenantForeignKey( 290 | Purchase, on_delete=models.CASCADE, blank=True, null=True 291 | ) 292 | product = TenantForeignKey(Product, on_delete=models.CASCADE) 293 | date = models.DateField(auto_now_add=True) 294 | 295 | 296 | class MigrationUseInMigrationsModel(TenantModel): 297 | name = models.CharField(max_length=255) 298 | 299 | class TenantMeta: 300 | tenant_field_name = "id" 301 | -------------------------------------------------------------------------------- /django_multitenant/mixins.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models.sql import DeleteQuery, UpdateQuery 4 | from django.db.models.deletion import Collector 5 | from django.db.utils import NotSupportedError 6 | from django.conf import settings 7 | 8 | import django 9 | from django.db.models.fields.related_descriptors import ( 10 | create_forward_many_to_many_manager, 11 | ) 12 | 13 | 14 | from .deletion import related_objects 15 | from .exceptions import EmptyTenant 16 | from .query import wrap_get_compiler, wrap_update_batch, wrap_delete 17 | from .utils import ( 18 | set_current_tenant, 19 | get_current_tenant, 20 | get_current_tenant_value, 21 | get_tenant_field, 22 | get_tenant_filters, 23 | get_object_tenant, 24 | set_object_tenant, 25 | get_tenant_column, 26 | ) 27 | 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def wrap_many_related_manager_add(many_related_manager_add): 33 | """ 34 | Wraps the add method of many to many field to set tenant_id in through_defaults 35 | parameter of the add method. 36 | """ 37 | 38 | def add(self, *objs, through_defaults=None): 39 | if hasattr(self.through, "tenant_field") and get_current_tenant(): 40 | through_defaults = through_defaults or {} 41 | through_defaults[get_tenant_column(self.through)] = ( 42 | get_current_tenant_value() 43 | ) 44 | return many_related_manager_add(self, *objs, through_defaults=through_defaults) 45 | 46 | return add 47 | 48 | 49 | def wrap_forward_many_to_many_manager(create_forward_many_to_many_manager_method): 50 | """ 51 | Wraps the create_forward_many_to_many_manager method of the related_descriptors module 52 | and changes the add method of the ManyRelatedManagerClass to set tenant_id in through_defaults 53 | """ 54 | 55 | def create_forward_many_to_many_manager_wrapper(superclass, rel, reverse): 56 | ManyRelatedManagerClass = create_forward_many_to_many_manager_method( 57 | superclass, rel, reverse 58 | ) 59 | ManyRelatedManagerClass.add = wrap_many_related_manager_add( 60 | ManyRelatedManagerClass.add 61 | ) 62 | return ManyRelatedManagerClass 63 | 64 | # pylint: disable=protected-access 65 | create_forward_many_to_many_manager_wrapper._sign = "add django-multitenant" 66 | return create_forward_many_to_many_manager_wrapper 67 | 68 | 69 | class TenantManagerMixin: 70 | # Below is the manager related to the above class. 71 | # Overrides the get_queryset method of to inject tenant_id filters in the get_queryset. 72 | # With this method, models extended from TenantManagerMixin will have tenant_id filters by default 73 | # if the tenant_id is set in the current thread. 74 | def get_queryset(self): 75 | # Injecting tenant_id filters in the get_queryset. 76 | # Injects tenant_id filter on the current model for all the non-join/join queries. 77 | queryset = self._queryset_class(self.model) 78 | current_tenant = get_current_tenant() 79 | if current_tenant: 80 | kwargs = get_tenant_filters(self.model) 81 | return queryset.filter(**kwargs) 82 | return queryset 83 | 84 | def bulk_create(self, objs, **kwargs): 85 | # Helper method to set tenant_id in the current thread for the results returned from query_set. 86 | # For example, if we have a query_set of all the users in the current tenant, we can set the tenant_id by calling 87 | # User.object.bulk_create(users) 88 | if get_current_tenant(): 89 | tenant_value = get_current_tenant_value() 90 | for obj in objs: 91 | set_object_tenant(obj, tenant_value) 92 | 93 | return super().bulk_create(objs, **kwargs) 94 | 95 | 96 | class TenantModelMixin: 97 | # Abstract model which all the models related to tenant inherit. 98 | 99 | def __init__(self, *args, **kwargs): 100 | # Adds tenant_id filters in the delete and update queries. 101 | 102 | # Below block decorates Delete operations related activities and adds tenant_id filters. 103 | 104 | if not hasattr(DeleteQuery.get_compiler, "_sign"): 105 | # Decorates the the compiler of DeleteQuery to add tenant_id filters. 106 | DeleteQuery.get_compiler = wrap_get_compiler(DeleteQuery.get_compiler) 107 | # Decorates related_objects to add tenant_id filters to add tenant_id filters. 108 | # related_objects is being used to define additional records for deletion defined with relations 109 | Collector.related_objects = related_objects 110 | # Decorates the delete method of Collector to execute citus shard_modify_mode commands 111 | # if distributed tables are being related to the model. 112 | Collector.delete = wrap_delete(Collector.delete) 113 | 114 | if not hasattr(create_forward_many_to_many_manager, "_sign"): 115 | django.db.models.fields.related_descriptors.create_forward_many_to_many_manager = wrap_forward_many_to_many_manager( 116 | create_forward_many_to_many_manager 117 | ) 118 | 119 | # Decorates the update_batch method of UpdateQuery to add tenant_id filters. 120 | if not hasattr(UpdateQuery.get_compiler, "_sign"): 121 | UpdateQuery.update_batch = wrap_update_batch(UpdateQuery.update_batch) 122 | 123 | super().__init__(*args, **kwargs) 124 | 125 | def __setattr__(self, attrname, val): 126 | # Provides failing of the save operation if the tenant_id is changed. 127 | # try_update_tenant is being checked inside save method and if it is true, it will raise an exception. 128 | def is_val_equal_to_tenant(val): 129 | return ( 130 | val 131 | and self.tenant_value 132 | and val != self.tenant_value 133 | and val != self.tenant_object 134 | ) 135 | 136 | if ( 137 | attrname in (self.tenant_field, get_tenant_field(self).name) 138 | and not self._state.adding 139 | and is_val_equal_to_tenant(val) 140 | ): 141 | self._try_update_tenant = True 142 | 143 | return super().__setattr__(attrname, val) 144 | 145 | # pylint: disable=too-many-arguments 146 | def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): 147 | # adding tenant filters for save 148 | # Citus requires tenant_id filters for update, hence doing this below change. 149 | 150 | current_tenant = get_current_tenant() 151 | 152 | if current_tenant: 153 | kwargs = get_tenant_filters(self.__class__) 154 | base_qs = base_qs.filter(**kwargs) 155 | else: 156 | empty_tenant_message = ( 157 | f"Attempting to update {self._meta.model.__name__} instance {self} " 158 | "without a current tenant set. " 159 | "This may cause issues in a partitioned environment. " 160 | "Recommend calling set_current_tenant() before performing this " 161 | "operation.", 162 | ) 163 | if getattr(settings, "TENANT_STRICT_MODE", False): 164 | raise EmptyTenant(empty_tenant_message) 165 | logger.warning(empty_tenant_message) 166 | 167 | return super()._do_update( 168 | base_qs, using, pk_val, values, update_fields, forced_update 169 | ) 170 | 171 | def save(self, *args, **kwargs): 172 | # Performs tenant related operations before and after save. 173 | # Synchronizes object tenant with the tenant set in the application in case of a mismatch 174 | # In normal cases _try_update_tenant should prevent tenant_id from being updated. 175 | # However, if the tenant_id is updated in the database directly, this will catch it. 176 | if hasattr(self, "_try_update_tenant"): 177 | raise NotSupportedError("Tenant column of a row cannot be updated.") 178 | 179 | current_tenant = get_current_tenant() 180 | tenant_value = get_current_tenant_value() 181 | 182 | set_object_tenant(self, tenant_value) 183 | 184 | if self.tenant_value and tenant_value != self.tenant_value: 185 | self_tenant = get_object_tenant(self) 186 | set_current_tenant(self_tenant) 187 | 188 | try: 189 | obj = super().save(*args, **kwargs) 190 | finally: 191 | set_current_tenant(current_tenant) 192 | 193 | return obj 194 | 195 | @property 196 | def tenant_field(self): 197 | if hasattr(self, "TenantMeta") and "tenant_field_name" in dir(self.TenantMeta): 198 | return self.TenantMeta.tenant_field_name 199 | if hasattr(self, "TenantMeta") and "tenant_id" in dir(self.TenantMeta): 200 | return self.TenantMeta.tenant_id 201 | if hasattr(self, "tenant"): 202 | raise AttributeError( 203 | f"Tenant field exists which may cause collision with tenant_id field. Please rename the tenant field in {self.__class__.__name__} " 204 | ) 205 | if hasattr(self, "tenant_id"): 206 | return self.tenant_id 207 | if self.__module__ == "__fake__": 208 | raise AttributeError( 209 | f"apps.get_model method should not be used to get the model {self.__class__.__name__}." 210 | "Either import the model directly or use the module apps under the module django.apps." 211 | ) 212 | 213 | raise AttributeError( 214 | f"tenant_id field not found. Please add tenant_id field to the model {self.__class__.__name__}" 215 | ) 216 | 217 | @property 218 | def tenant_value(self): 219 | return getattr(self, self.tenant_field, None) 220 | 221 | @property 222 | def tenant_object(self): 223 | return get_object_tenant(self) 224 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-multitenant
[![Build Status](https://github.com/citusdata/django-multitenant/actions/workflows/django-multitenant-tests.yml/badge.svg)](https://github.com/citusdata/django-multitenant/actions/workflows/django-multitenant-tests.yml) [![Latest Documentation Status](https://readthedocs.org/projects/django-multitenant/badge/?version=latest)](https://django-multitenant.readthedocs.io/en/latest/?badge=latest) [![Coverage Status](https://codecov.io/gh/citusdata/django-multitenant/branch/main/graph/badge.svg?token=taRgoSgHUw)](https://codecov.io/gh/citusdata/django-multitenant) [![PyPI Version](https://badge.fury.io/py/django-multitenant.svg)](https://badge.fury.io/py/django-multitenant) 2 | 3 | Python/Django support for distributed multi-tenant databases like Postgres+Citus 4 | 5 | Enables easy scale-out by adding the tenant context to your queries, enabling the database (e.g. Citus) to efficiently route queries to the right database node. 6 | 7 | There are architecures for building multi-tenant databases viz. **Create one database per tenant**, **Create one schema per tenant** and **Have all tenants share the same table(s)**. This library is based on the 3rd design i.e **Have all tenants share the same table(s)**, it assumes that all the tenant relates models/tables have a tenant_id column for representing a tenant. 8 | 9 | The following link talks more about the trade-offs on when and how to choose the right architecture for your multi-tenant database: 10 | 11 | https://www.citusdata.com/blog/2016/10/03/designing-your-saas-database-for-high-scalability/ 12 | 13 | The following blogpost is a good starting point to start to use django-multitenant 14 | https://www.citusdata.com/blog/2023/05/09/evolving-django-multitenant-to-build-scalable-saas-apps-on-postgres-and-citus/ 15 | 16 | **Other useful links on multi-tenancy**: 17 | 1. https://www.citusdata.com/blog/2017/03/09/multi-tenant-sharding-tutorial/ 18 | 1. https://www.citusdata.com/blog/2017/06/02/scaling-complex-sql-transactions/ 19 | 1. https://www.youtube.com/watch?v=RKSwjaZKXL0 20 | 21 | 22 | ## Installation: 23 | 1. `pip install --no-cache-dir django_multitenant` 24 | 25 | ## Supported Django versions/Pre-requisites. 26 | 27 | | Python | Django |Citus | 28 | | ----------------------| --------------|---------------| 29 | | 3.8 3.9 3.10 3.11 | 4.2 | 11 12 | 30 | | 3.8 3.9 3.10 3.11 | 4.1 | 11 12 | 31 | | 3.8 3.9 3.10 3.11 | 4.0 | 10 11 12 | 32 | | 3.7 | 3.2 | 10 11 12 | 33 | 34 | 35 | 36 | 37 | ## Usage: 38 | 39 | In order to use this library you can either use Mixins or have your models inherit from our custom model class. 40 | 41 | 42 | ### Changes in Models: 43 | 1. In whichever files you want to use the library import it: 44 | ```python 45 | from django_multitenant.fields import * 46 | from django_multitenant.models import * 47 | ``` 48 | 2. All models should inherit the TenantModel class. 49 | `Ex: class Product(TenantModel):` 50 | 3. Define a static variable named tenant_id and specify the tenant column using this variable.You can define tenant_id in three ways. Any of them is acceptable 51 | * Using TenantMeta.tenant_field_name variable 52 | * Using TenantMeta.tenant_id variable 53 | * Using tenant_id field 54 |
55 | 56 | 57 | > **Warning** 58 | > Using tenant_id field directly in the class is not suggested since it may cause collision if class has a field named with 'tenant' 59 |
60 | 61 | 4. All foreign keys to TenantModel subclasses should use TenantForeignKey in place of 62 | models.ForeignKey 63 | 5. A sample model implementing the above 2 steps: 64 | ```python 65 | class Store(TenantModel): 66 | name = models.CharField(max_length=50) 67 | address = models.CharField(max_length=255) 68 | email = models.CharField(max_length=50) 69 | class TenantMeta: 70 | tenant_field_name = "id" 71 | 72 | class Product(TenantModel): 73 | store = models.ForeignKey(Store) 74 | name = models.CharField(max_length=255) 75 | description = models.TextField() 76 | class Meta: 77 | unique_together = ["id", "store"] 78 | class TenantMeta: 79 | tenant_field_name = "store_id" 80 | class Purchase(TenantModel): 81 | store = models.ForeignKey(Store) 82 | product_purchased = TenantForeignKey(Product) 83 | class TenantMeta: 84 | tenant_field_name = "store_id" 85 | ``` 86 | 87 | 88 | ### Changes in Models using mixins: 89 | 1. In whichever files you want to use the library import it by just saying 90 | ```python 91 | from django_multitenant.mixins import * 92 | ``` 93 | 1. All models should use the `TenantModelMixin` and the django `models.Model` or your customer Model class 94 | `Ex: class Product(TenantModelMixin, models.Model):` 95 | 1. Define a static variable named tenant_id and specify the tenant column using this variable. 96 | `Ex: tenant_id='store_id'` 97 | 1. All foreign keys to TenantModel subclasses should use TenantForeignKey in place of 98 | models.ForeignKey 99 | 1. Referenced table in TenantForeignKey should include a unique key including tenant_id and primary key 100 | ``` 101 | Ex: 102 | class Meta: 103 | unique_together = ["id", "store"] 104 | ``` 105 | 1. A sample model implementing the above 3 steps: 106 | ```python 107 | 108 | class ProductManager(TenantManagerMixin, models.Manager): 109 | pass 110 | 111 | class Product(TenantModelMixin, models.Model): 112 | store = models.ForeignKey(Store) 113 | tenant_id='store_id' 114 | name = models.CharField(max_length=255) 115 | description = models.TextField() 116 | 117 | objects = ProductManager() 118 | 119 | class Meta: 120 | unique_together = ["id", "store"] 121 | 122 | class PurchaseManager(TenantManagerMixin, models.Manager): 123 | pass 124 | 125 | class Purchase(TenantModelMixin, models.Model): 126 | store = models.ForeignKey(Store) 127 | tenant_id='store_id' 128 | product_purchased = TenantForeignKey(Product) 129 | 130 | objects = PurchaseManager() 131 | ``` 132 | 133 | 134 | 135 | ### Automating composite foreign keys at db layer: 136 | 1. Creating foreign keys between tenant related models using TenantForeignKey would automate adding tenant_id to reference queries (ex. product.purchases) and join queries (ex. product__name). If you want to ensure to create composite foreign keys (with tenant_id) at the db layer, you should change the database ENGINE in the settings.py to `django_multitenant.backends.postgresql`. 137 | ```python 138 | 'default': { 139 | 'ENGINE': 'django_multitenant.backends.postgresql', 140 | ...... 141 | ...... 142 | ...... 143 | } 144 | ``` 145 | ### Where to Set the Tenant? 146 | 1. Write authentication logic using a middleware which also sets/unsets a tenant for each session/request. This way developers need not worry about setting a tenant on a per view basis. Just set it while authentication and the library would ensure the rest (adding tenant_id filters to the queries). A sample implementation of the above is as follows: 147 | ```python 148 | from django_multitenant.utils import set_current_tenant 149 | 150 | class MultitenantMiddleware: 151 | def __init__(self, get_response): 152 | self.get_response = get_response 153 | 154 | def __call__(self, request): 155 | if request.user and not request.user.is_anonymous: 156 | set_current_tenant(request.user.employee.company) 157 | return self.get_response(request) 158 | ``` 159 | 160 | In your settings, you will need to update the `MIDDLEWARE` setting to include the one you created. 161 | ```python 162 | MIDDLEWARE = [ 163 | # ... 164 | # existing items 165 | # ... 166 | 'appname.middleware.MultitenantMiddleware' 167 | ] 168 | ``` 169 | 2. Set the tenant using set_current_tenant(t) api in all the views which you want to be scoped based on tenant. This would scope all the django API calls automatically(without specifying explicit filters) to a single tenant. If the current_tenant is not set, then the default/native API without tenant scoping is used. 170 | ```python 171 | def application_function: 172 | # current_tenant can be stored as a SESSION variable when a user logs in. 173 | # This should be done by the app 174 | t = current_tenant 175 | #set the tenant 176 | set_current_tenant(t); 177 | #Django ORM API calls; 178 | #Command 1; 179 | #Command 2; 180 | #Command 3; 181 | #Command 4; 182 | #Command 5; 183 | ``` 184 | 185 | ## Supported APIs: 186 | 1. Most of the APIs under Model.objects.*. 187 | 1. Model.save() injects tenant_id for tenant inherited models. 188 | ```python 189 | s=Store.objects.all()[0] 190 | set_current_tenant(s) 191 | 192 | #All the below API calls would add suitable tenant filters. 193 | #Simple get_queryset() 194 | Product.objects.get_queryset() 195 | 196 | #Simple join 197 | Purchase.objects.filter(id=1).filter(store__name='The Awesome Store').filter(product__description='All products are awesome') 198 | 199 | #Update 200 | Purchase.objects.filter(id=1).update(id=1) 201 | 202 | #Save 203 | p=Product(8,1,'Awesome Shoe','These shoes are awesome') 204 | p.save() 205 | 206 | #Simple aggregates 207 | Product.objects.count() 208 | Product.objects.filter(store__name='The Awesome Store').count() 209 | 210 | #Subqueries 211 | Product.objects.filter(name='Awesome Shoe'); 212 | Purchase.objects.filter(product__in=p); 213 | 214 | ``` 215 | 216 | ## Credits 217 | 218 | This library uses similar logic of setting/getting tenant object as in [django-simple-multitenant](https://github.com/pombredanne/django-simple-multitenant). We thank the authors for their efforts. 219 | 220 | ## License 221 | 222 | Copyright (C) 2023, Citus Data 223 | Licensed under the MIT license, see LICENSE file for details. 224 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ================================= 3 | 4 | In order to use this library you can either use Mixins or have your 5 | models inherit from our custom model class. 6 | 7 | Changes in Models 8 | ----------------- 9 | 10 | 1. In whichever files you want to use the library import it: 11 | 12 | .. code:: python 13 | 14 | from django_multitenant.fields import * 15 | from django_multitenant.models import * 16 | 17 | 2. All models should inherit the TenantModel class. 18 | ``Ex: class Product(TenantModel):`` 19 | 20 | 3. Define a static variable named tenant_id and specify the tenant 21 | column using this variable. ``Ex: tenant_id='store_id'`` 22 | 23 | 4. All foreign keys to TenantModel subclasses should use 24 | TenantForeignKey in place of models.ForeignKey 25 | 26 | 5. A sample model implementing the above 2 steps: 27 | 28 | .. code:: python 29 | 30 | class Store(TenantModel): 31 | tenant_id = 'id' 32 | name = models.CharField(max_length=50) 33 | address = models.CharField(max_length=255) 34 | email = models.CharField(max_length=50) 35 | 36 | class Product(TenantModel): 37 | store = models.ForeignKey(Store) 38 | tenant_id='store_id' 39 | name = models.CharField(max_length=255) 40 | description = models.TextField() 41 | class Meta(object): 42 | unique_together = ["id", "store"] 43 | class Purchase(TenantModel): 44 | store = models.ForeignKey(Store) 45 | tenant_id='store_id' 46 | product_purchased = TenantForeignKey(Product) 47 | 48 | 49 | Reserved tenant_id keyword 50 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 51 | tenant_id column name should not be 'tenant_id'. 'tenant_id' is a reserved keyword across the library. 52 | 53 | Example model with correct tenant_id column name: 54 | 55 | .. code:: python 56 | 57 | class Tenant 58 | tenant_id = 'id' 59 | 60 | class Business(TenantModel): 61 | ten = models.ForeignKey(Tenant, blank=True, null=True, on_delete=models.SET_NULL) 62 | tenant_id = 'tenant_id' # This is wrong 63 | tenant_id = 'ten_id' # This is correct 64 | 65 | 66 | Changes in Models using mixins 67 | ------------------------------- 68 | 69 | 1. In whichever files you want to use the library import it by just 70 | saying 71 | 72 | .. code:: python 73 | 74 | from django_multitenant.mixins import * 75 | 76 | 2. All models should use the ``TenantModelMixin`` and the django 77 | ``models.Model`` or your customer Model class 78 | ``Ex: class Product(TenantModelMixin, models.Model):`` 79 | 80 | 3. Define a static variable named tenant_id and specify the tenant 81 | column using this variable. ``Ex: tenant_id='store_id'`` 82 | 83 | 4. All foreign keys to TenantModel subclasses should use 84 | TenantForeignKey in place of models.ForeignKey 85 | 86 | 5. Referenced table in TenenatForeignKey should include a unique key 87 | including tenant_id and primary key 88 | 89 | :: 90 | 91 | Ex: 92 | class Meta(object): 93 | unique_together = ["id", "store"] 94 | 95 | 6. A sample model implementing the above 3 steps: 96 | 97 | .. code:: python 98 | 99 | 100 | class ProductManager(TenantManagerMixin, models.Manager): 101 | pass 102 | 103 | class Product(TenantModelMixin, models.Model): 104 | store = models.ForeignKey(Store) 105 | tenant_id='store_id' 106 | name = models.CharField(max_length=255) 107 | description = models.TextField() 108 | 109 | objects = ProductManager() 110 | 111 | class Meta(object): 112 | unique_together = ["id", "store"] 113 | 114 | class PurchaseManager(TenantManagerMixin, models.Manager): 115 | pass 116 | 117 | class Purchase(TenantModelMixin, models.Model): 118 | store = models.ForeignKey(Store) 119 | tenant_id='store_id' 120 | product_purchased = TenantForeignKey(Product) 121 | 122 | objects = PurchaseManager() 123 | 124 | Changes in Migrations 125 | --------------------- 126 | 127 | Typical Django ORM migrations use ``apps.get_model()`` in `RunPython 128 | `_ 129 | to get a model from the app registry. For example: 130 | 131 | .. code:: python 132 | 133 | # normal way -- does NOT work in Django Multitenant 134 | 135 | def forwards_func(apps, schema_editor): 136 | MigrationUseInMigrationsModel = apps.get_model("tests", "MigrationUseInMigrationsModel") 137 | MigrationUseInMigrationsModel.objects.create(name="test") 138 | 139 | However the ``get_model`` method creates "fake" models that lack transient 140 | fields, and Django Multitenant relies on the ``tenant_id`` transient field to 141 | function properly. When doing ORM database migrations with Django Multitenant, 142 | you'll need to get the model differently. 143 | 144 | Here are two alternatives. 145 | 146 | 1. Use the ``apps`` module rather than the ``apps`` parameter in RunPython 147 | methods (such as ``forwards_func``) to get the model you want to use: 148 | 149 | .. code:: python 150 | 151 | from django.apps import apps 152 | 153 | def forwards_func(ignored, schema_editor): 154 | MigrationUseInMigrationsModel = apps.get_model("tests", "MigrationUseInMigrationsModel") 155 | MigrationUseInMigrationsModel.objects.create(name="test") 156 | 157 | 2. Directly import the class from models: 158 | 159 | .. code:: python 160 | 161 | from .models import MigrationUseInMigrationsModel 162 | 163 | def forwards_func(ignored, schema_editor): 164 | MigrationUseInMigrationsModel.objects.create(name="test") 165 | 166 | Automating composite foreign keys at db layer 167 | ---------------------------------------------- 168 | 169 | 1. Creating foreign keys between tenant related models using 170 | TenantForeignKey would automate adding tenant_id to reference queries 171 | (ex. product.purchases) and join queries (ex. product__name). If you 172 | want to ensure to create composite foreign keys (with tenant_id) at 173 | the db layer, you should change the database ENGINE in the 174 | settings.py to ``django_multitenant.backends.postgresql``. 175 | 176 | .. code:: python 177 | 178 | 'default': { 179 | 'ENGINE': 'django_multitenant.backends.postgresql', 180 | ...... 181 | ...... 182 | ...... 183 | } 184 | 185 | Where to Set the Tenant? 186 | ------------------------ 187 | 188 | 1. Write authentication logic using a middleware which also sets/unsets 189 | a tenant for each session/request. This way developers need not worry 190 | about setting a tenant on a per view basis. Just set it while 191 | authentication and the library would ensure the rest (adding 192 | tenant_id filters to the queries). A sample implementation of the 193 | above is as follows: 194 | 195 | .. code:: python 196 | 197 | from django_multitenant.utils import set_current_tenant, unset_current_tenant 198 | from django.contrib.auth import logout 199 | 200 | 201 | class MultitenantMiddleware: 202 | def __init__(self, get_response): 203 | self.get_response = get_response 204 | 205 | def __call__(self, request): 206 | if request.user and not request.user.is_anonymous: 207 | if not request.user.account and not request.user.is_superuser: 208 | print( 209 | "Logging out because user doesnt have account and not a superuser" 210 | ) 211 | logout(request.user) 212 | 213 | set_current_tenant(request.user.account) 214 | 215 | response = self.get_response(request) 216 | 217 | """ 218 | The following unsetting of the tenant is essential because of how webservers work 219 | Since the tenant is set as a thread local, the thread is not killed after the request is processed 220 | So after processing of the request, we need to ensure that the tenant is unset 221 | Especially required if you have public users accessing the site 222 | 223 | This is also essential if you have admin users not related to a tenant (not possible in actual citus env) 224 | """ 225 | unset_current_tenant() 226 | 227 | return response 228 | 229 | 230 | In your settings, you will need to update the ``MIDDLEWARE`` setting 231 | to include the one you created. 232 | 233 | .. code:: python 234 | 235 | MIDDLEWARE = [ 236 | # ... 237 | # existing items 238 | # ... 239 | 'appname.middleware.MultitenantMiddleware' 240 | ] 241 | 242 | 2. Set the tenant using set_current_tenant(t) api in all the views which 243 | you want to be scoped based on tenant. This would scope all the 244 | django API calls automatically(without specifying explicit filters) 245 | to a single tenant. If the current_tenant is not set, then the 246 | default/native API without tenant scoping is used. 247 | 248 | .. code:: python 249 | 250 | def application_function: 251 | # current_tenant can be stored as a SESSION variable when a user logs in. 252 | # This should be done by the app 253 | t = current_tenant 254 | #set the tenant 255 | set_current_tenant(t); 256 | #Django ORM API calls; 257 | #Command 1; 258 | #Command 2; 259 | #Command 3; 260 | #Command 4; 261 | #Command 5; 262 | 263 | Supported APIs 264 | ================================= 265 | 266 | 1. Most of the APIs under Model.objects.*. 267 | 2. Model.save() injects tenant_id for tenant inherited models. 268 | 269 | .. code:: python 270 | 271 | s=Store.objects.all()[0] 272 | set_current_tenant(s) 273 | 274 | #All the below API calls would add suitable tenant filters. 275 | #Simple get_queryset() 276 | Product.objects.get_queryset() 277 | 278 | #Simple join 279 | Purchase.objects.filter(id=1).filter(store__name='The Awesome Store').filter(product__description='All products are awesome') 280 | 281 | #Update 282 | Purchase.objects.filter(id=1).update(id=1) 283 | 284 | #Save 285 | p=Product(8,1,'Awesome Shoe','These shoes are awesome') 286 | p.save() 287 | 288 | #Simple aggregates 289 | Product.objects.count() 290 | Product.objects.filter(store__name='The Awesome Store').count() 291 | 292 | #Subqueries 293 | Product.objects.filter(name='Awesome Shoe'); 294 | Purchase.objects.filter(product__in=p); 295 | 296 | Credits 297 | ================================= 298 | 299 | This library uses similar logic of setting/getting tenant object as in 300 | `django-simple-multitenant `__. 301 | We thank the authors for their efforts. 302 | -------------------------------------------------------------------------------- /django_multitenant/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-11-14 17:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Account", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("name", models.CharField(max_length=255)), 29 | ("domain", models.CharField(max_length=255)), 30 | ("subdomain", models.CharField(max_length=255)), 31 | ], 32 | options={ 33 | "abstract": False, 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name="AliasedTask", 38 | fields=[ 39 | ( 40 | "id", 41 | models.BigAutoField( 42 | auto_created=True, 43 | primary_key=True, 44 | serialize=False, 45 | verbose_name="ID", 46 | ), 47 | ), 48 | ( 49 | "account", 50 | models.ForeignKey( 51 | on_delete=django.db.models.deletion.CASCADE, to="tests.Account" 52 | ), 53 | ), 54 | ], 55 | options={ 56 | "abstract": False, 57 | }, 58 | ), 59 | migrations.CreateModel( 60 | name="Country", 61 | fields=[ 62 | ( 63 | "id", 64 | models.BigAutoField( 65 | auto_created=True, 66 | primary_key=True, 67 | serialize=False, 68 | verbose_name="ID", 69 | ), 70 | ), 71 | ("name", models.CharField(max_length=255)), 72 | ], 73 | ), 74 | migrations.CreateModel( 75 | name="Manager", 76 | fields=[ 77 | ( 78 | "id", 79 | models.BigAutoField( 80 | auto_created=True, 81 | primary_key=True, 82 | serialize=False, 83 | verbose_name="ID", 84 | ), 85 | ), 86 | ("name", models.CharField(max_length=255)), 87 | ( 88 | "account", 89 | models.ForeignKey( 90 | on_delete=django.db.models.deletion.CASCADE, to="tests.Account" 91 | ), 92 | ), 93 | ], 94 | options={ 95 | "abstract": False, 96 | }, 97 | ), 98 | migrations.CreateModel( 99 | name="Organization", 100 | fields=[ 101 | ( 102 | "id", 103 | models.UUIDField( 104 | default=uuid.uuid4, 105 | editable=False, 106 | primary_key=True, 107 | serialize=False, 108 | ), 109 | ), 110 | ("name", models.CharField(max_length=255)), 111 | ], 112 | options={ 113 | "abstract": False, 114 | }, 115 | ), 116 | migrations.CreateModel( 117 | name="Project", 118 | fields=[ 119 | ( 120 | "id", 121 | models.BigAutoField( 122 | auto_created=True, 123 | primary_key=True, 124 | serialize=False, 125 | verbose_name="ID", 126 | ), 127 | ), 128 | ("name", models.CharField(max_length=255)), 129 | ( 130 | "account", 131 | models.ForeignKey( 132 | on_delete=django.db.models.deletion.CASCADE, 133 | related_name="account", 134 | to="tests.Account", 135 | ), 136 | ), 137 | ], 138 | options={ 139 | "abstract": False, 140 | }, 141 | ), 142 | migrations.CreateModel( 143 | name="ProjectManager", 144 | fields=[ 145 | ( 146 | "id", 147 | models.BigAutoField( 148 | auto_created=True, 149 | primary_key=True, 150 | serialize=False, 151 | verbose_name="ID", 152 | ), 153 | ), 154 | ( 155 | "account", 156 | models.ForeignKey( 157 | on_delete=django.db.models.deletion.CASCADE, to="tests.Account" 158 | ), 159 | ), 160 | ( 161 | "manager", 162 | models.ForeignKey( 163 | on_delete=django.db.models.deletion.CASCADE, to="tests.Manager" 164 | ), 165 | ), 166 | ( 167 | "project", 168 | models.ForeignKey( 169 | on_delete=django.db.models.deletion.CASCADE, to="tests.Project" 170 | ), 171 | ), 172 | ], 173 | options={ 174 | "abstract": False, 175 | }, 176 | ), 177 | migrations.CreateModel( 178 | name="Record", 179 | fields=[ 180 | ( 181 | "id", 182 | models.UUIDField( 183 | default=uuid.uuid4, 184 | editable=False, 185 | primary_key=True, 186 | serialize=False, 187 | ), 188 | ), 189 | ("name", models.CharField(max_length=255)), 190 | ( 191 | "organization", 192 | models.ForeignKey( 193 | on_delete=django.db.models.deletion.CASCADE, 194 | to="tests.Organization", 195 | ), 196 | ), 197 | ], 198 | options={ 199 | "abstract": False, 200 | }, 201 | ), 202 | migrations.CreateModel( 203 | name="SubTask", 204 | fields=[ 205 | ( 206 | "id", 207 | models.BigAutoField( 208 | auto_created=True, 209 | primary_key=True, 210 | serialize=False, 211 | verbose_name="ID", 212 | ), 213 | ), 214 | ("name", models.CharField(max_length=255)), 215 | ("type", models.CharField(max_length=255)), 216 | ( 217 | "account", 218 | models.ForeignKey( 219 | on_delete=django.db.models.deletion.CASCADE, to="tests.Account" 220 | ), 221 | ), 222 | ], 223 | options={ 224 | "abstract": False, 225 | }, 226 | ), 227 | migrations.CreateModel( 228 | name="Task", 229 | fields=[ 230 | ( 231 | "id", 232 | models.BigAutoField( 233 | auto_created=True, 234 | primary_key=True, 235 | serialize=False, 236 | verbose_name="ID", 237 | ), 238 | ), 239 | ("name", models.CharField(max_length=255)), 240 | ( 241 | "account", 242 | models.ForeignKey( 243 | on_delete=django.db.models.deletion.CASCADE, to="tests.Account" 244 | ), 245 | ), 246 | ( 247 | "project", 248 | models.ForeignKey( 249 | on_delete=django.db.models.deletion.CASCADE, 250 | related_name="tasks", 251 | to="tests.Project", 252 | ), 253 | ), 254 | ], 255 | options={ 256 | "abstract": False, 257 | }, 258 | ), 259 | migrations.CreateModel( 260 | name="UnscopedModel", 261 | fields=[ 262 | ( 263 | "id", 264 | models.BigAutoField( 265 | auto_created=True, 266 | primary_key=True, 267 | serialize=False, 268 | verbose_name="ID", 269 | ), 270 | ), 271 | ("name", models.CharField(max_length=255)), 272 | ], 273 | ), 274 | migrations.CreateModel( 275 | name="SomeRelatedModel", 276 | fields=[ 277 | ( 278 | "id", 279 | models.BigAutoField( 280 | auto_created=True, 281 | primary_key=True, 282 | serialize=False, 283 | verbose_name="ID", 284 | ), 285 | ), 286 | ("name", models.CharField(max_length=255)), 287 | ], 288 | options={ 289 | "abstract": False, 290 | }, 291 | ), 292 | migrations.CreateModel( 293 | name="TenantNotIdModel", 294 | fields=[ 295 | ( 296 | "tenant_column", 297 | models.IntegerField(primary_key=True, serialize=False), 298 | ), 299 | ("name", models.CharField(max_length=255)), 300 | ], 301 | options={ 302 | "abstract": False, 303 | }, 304 | ), 305 | migrations.AddField( 306 | model_name="somerelatedmodel", 307 | name="related_tenant", 308 | field=models.ForeignKey( 309 | on_delete=django.db.models.deletion.CASCADE, to="tests.TenantNotIdModel" 310 | ), 311 | ), 312 | migrations.AddField( 313 | model_name="subtask", 314 | name="task", 315 | field=models.ForeignKey( 316 | on_delete=django.db.models.deletion.CASCADE, to="tests.Task" 317 | ), 318 | ), 319 | migrations.AddField( 320 | model_name="project", 321 | name="managers", 322 | field=models.ManyToManyField( 323 | through="tests.ProjectManager", to="tests.Manager" 324 | ), 325 | ), 326 | migrations.AddField( 327 | model_name="aliasedtask", 328 | name="project_alias", 329 | field=models.ForeignKey( 330 | on_delete=django.db.models.deletion.CASCADE, to="tests.Project" 331 | ), 332 | ), 333 | migrations.AddField( 334 | model_name="account", 335 | name="country", 336 | field=models.ForeignKey( 337 | on_delete=django.db.models.deletion.CASCADE, to="tests.Country" 338 | ), 339 | ), 340 | ] 341 | --------------------------------------------------------------------------------