├── 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
[](https://github.com/citusdata/django-multitenant/actions/workflows/django-multitenant-tests.yml) [](https://django-multitenant.readthedocs.io/en/latest/?badge=latest) [](https://codecov.io/gh/citusdata/django-multitenant) [](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 |
--------------------------------------------------------------------------------