Setup django with multitenancy (semi-isolated)

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

youtube video showing how to setup django-tenants

django-multitenant on github

YouTube video on django-multitenant