Setup Django with django-tenancy (semi-isolated)
This story will show how to install setup Django to work with multiple clients, like a real SAAS.
Prerequisites
I'm working on an Ubuntu 20.04, Django 3.1.1 and django-tenants 3.2.1. Also, I'm assuming you have a running postgres server running. At the bottom, I'm including a docker-compose file that you can run that will handle that part.
What is multitenancy?
Multitenancy refers to the functionality of serving multiple different customers (tenants) using the same platform, for us, Django. Think Slack or Shopify or any other SAAS where the customers share a platform but still each have their individual 'whitelabel'.
4 different models
In general, there are 4 different approaches one can take to serve multiple tenants.
0 Completely isolated deployments
This is not traditionally considered a solution as no resources at all are shared. You simply deploy a new separate installation of your django and database to a separate server, one for each tenant.
1 Isolated
In this approach, each tenant has their own database but share the Django instance. This can be easily achieved as Django supports multiple databases out of the box. It doesn't scale well though but might be attractive if you have few customers who value their data privacy.
2 Semi-isolated
In this approach, each tenant will share the same database but each will have their own schema. This is the approach we will use in this story.
3 Shared
In the shared approach, there is one database and one schema. Multi-tenancy is achieved by having each and every table have a foreignkey relationship with a Tenant table. This approach scales very well.
Hands on setup
Install and setup django
pip install django django-tenants psycopg2-binary
Create django project
django-admin startproject config .
Create app to hold public client/tenants schema:
python manage.py startapp clients
Create models
In client/models.py
from django.db import models
from django_tenants.models import TenantMixin, DomainMixin
class Client(TenantMixin):
name = models.CharField(max_length=100)
class Domain(DomainMixin):
pass
Update settings
import os
import os
ALLOWED_HOSTS = ["*"]
and templates dir:
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
Add to installed apps:
INSTALLED_APPS = [
'django_tenants',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
Enable middleware:
MIDDLEWARE = [
'django_tenants.middleware.main.TenantMainMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
Database engine:
DATABASES = {
'default': {
'ENGINE': 'django_tenants.postgresql_backend',
'NAME': os.environ.get('DATABASE_DB', 'mytickets'),
'USER': os.environ.get('DATABASE_USER', 'postgres'),
'PASSWORD': os.environ.get('DATABASE_PASSWORD', '12345'),
'HOST': os.environ.get('DATABASE_HOST', 'localhost'),
'PORT': os.environ.get('DATABASE_PORT', '5432'),
}
}
Add Clients app to installed apps
INSTALLED_APPS = [
'django_tenants',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'clients',
]
Add links to models:
TENANT_MODEL = "clients.Client" # app.Model
TENANT_DOMAIN_MODEL = "clients.Domain" # app.Model
Add routing
DATABASE_ROUTERS = (
'django_tenants.routers.TenantSyncRouter',
)
Update shared and tenant apps:
SHARED_APPS = [
'django_tenants',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'clients',
]
TENANT_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
]
INSTALLED_APPS = list(set(SHARED_APPS + TENANT_APPS))
Run migrations
python manage.py makemigrations
python manage.py migrate_schemas --shared
Create tenants and tenant app
python manage.py startapp products
Add model to new app:
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=127)
Make migrations and migrate
python manage.py makemigrations
python manage.py migrate_schemas
Add to admin
from django.contrib import admin
from products.models import Product
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
pass
python manage.py create_tenant
Remember to setup the /etc/hosts file to have the domains point to 127.0.0.1
Create superuser
(venv) $:~/mytickets$ python manage.py create_tenant_superuser
Enter Tenant Schema ('?' to list schemas): t1
Username (leave blank to use 'perwagner'): pert1
Email address: pert1@gmail.com
Password:
Password (again):
Superuser created successfully.
(venv) $:~/mytickets$ python manage.py create_tenant_superuser
Enter Tenant Schema ('?' to list schemas): t2
Username (leave blank to use 'perwagner'): pert2
Email address: pert2@gmail.com
Password:
Password (again):
Superuser created successfully.
(venv) $:~/mytickets$
Test the website for the tenenats on:
http://t1.mytickets.local:8000/
http://t2.mytickets.local:8000/
Create view
We need to make public and private URL routes in settings
In settings:
PUBLIC_SCHEMA_URLCONF = 'config.public_urls'
ROOT_URLCONF = 'config.urls'
create the public_urls.py file in /config
create view in product/views.py:
from django.views.generic import ListView
from products.models import Product
class ProductListView(ListView):
model = Product
template_name = 'products.html'
context_object_name = 'products'
create "templates" folder in project root, create products.html in there:
<htm>
<body>
<ul>
{% for product in products %}
<li>{{ product.name }}</li>
{% endfor %}
</ul>
</body>
</htm>
update config/urls.py:
from django.contrib import admin
from django.urls import path
from products.views import ProductListView
urlpatterns = [
path('admin/', admin.site.urls),
path('', ProductListView.as_view(), name='product_list_view')
]
Test
Go to t1.mytickets.local and t2.mytickets.local and test. Don't forget to update your local hosts file in etc/hosts to include:
127.0.0.1 t1.mytickets.local
127.0.0.1 t2.mytickets.local
docker-compose with postgres
version: '3.7'
services:
db:
container_name: postgres
image: postgres:alpine
ports:
- 5432:5432
volumes:
- db-data:/var/lib/postgresql/mytickets
environment:
POSTGRES_PASSWORD: 12345
POSTGRES_USER: postgres
POSTGRES_DB: mytickets
volumes:
db-data:
References
https://django-tenants.readthedocs.io/en/latest/install.html