Shared database with shared schema¶
In this chapter, we will rebuild a slightly modified Django polls app to be multi-tenant. You can download the code from Github.
The base single-tenant app¶
Our base project has one app called polls. The models look something like this.
from django.db import models
from django.contrib.auth.models import User
class Poll(models.Model):
question = models.CharField(max_length=100)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
pub_date = models.DateTimeField(auto_now=True)
def __str__(self):
return self.question
class Choice(models.Model):
poll = models.ForeignKey(Poll, related_name='choices',on_delete=models.CASCADE)
choice_text = models.CharField(max_length=100)
def __str__(self):
return self.choice_text
class Vote(models.Model):
choice = models.ForeignKey(Choice, related_name='votes', on_delete=models.CASCADE)
poll = models.ForeignKey(Poll, on_delete=models.CASCADE)
voted_by = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
unique_together = ("poll", "voted_by")
There are a number of other files which we will look at later.
Adding multi tenancy to models¶
We will add another app called tenants
python manage.py startapp tenants
Create a model for storing Tenant data.
class Tenant(models.Model):
name = models.CharField(max_length=100)
subdomain_prefix = models.CharField(max_length=100, unique=True)
And then create a class TenantAwareModel class which other models will subclass from.
class TenantAwareModel(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
class Meta:
abstract = True
Change the polls.models to subclass from TenantAwareModel.
# ...
class Poll(TenantAwareModel):
# ...
class Choice(TenantAwareModel):
# ...
class Vote(TenantAwareModel):
# ...
Identifying tenants¶
There are many approaches to identify the tenant. One common method is to give each tenant their own subdomain. So if you main website is
www.example.com
And each of the following will be a separate tenant.
- thor.example.com
- loki.example.com
- potter.example.com
We will use the same method in the rest of the book. Our Tenant model has subdomain_prefix which will identify the tenant.
We will use polls.local as the main domain and <xxx>.polls.local as tenant subdomain.
Extracting tenant from request¶
Django views always have a request which has the Host header. This will contain the full subdomain the tenant is using.
We will add some utility methods to do this. Create a utils.py and add this code.
from .models import Tenant
def hostname_from_request(request):
# split on `:` to remove port
return request.get_host().split(':')[0].lower()
def tenant_from_request(request):
hostname = hostname_from_request(request)
subdomain_prefix = hostname.split('.')[0]
return Tenant.objects.filter(subdomain_prefix=subdomain_prefix).first()
Now wherever you have a request, you can use tenant_from_request to get the tenant.
A detour to /etc/hosts¶
To ensure that the <xxx>.polls.local hits your development machine, make sure you add a few entries to your /etc/hosts
(If you are on windows, use C:\Windows\System32\Drivers\etc\hosts). My file looks like this.
# ...
127.0.0.1 polls.local
127.0.0.1 thor.polls.local
127.0.0.1 potter.polls.local
Also update ALLOWED_HOSTS your settings.py. Mine looks like this: ALLOWED_HOSTS = ['polls.local', '.polls.local'].
Using tenant_from_request in the views¶
Views, whether they are Django function based, class based or a Django Rest Framework view have access to the request.
Lets take the example of polls.views.PollViewSet to limit the endpoints to tenant specific Poll objects.
from tenants.utils import tenant_from_request
class PollViewSet(viewsets.ModelViewSet):
queryset = Poll.objects.all()
serializer_class = PollSerializer
def get_queryset(self):
tenant = tenant_from_request(self.request)
return super().get_queryset().filter(tenant=tenant)
Isolating the admin¶
Like the views we need to enforce tenant isolation on the admin. We will need to override two methods.
get_queryset: So that only the current tenant’s objects show up.save_model: So that tenant gets set on the object when the object is saved.
With the changes, your admin.py looks something like this.
@admin.register(Poll)
class PollAdmin(admin.ModelAdmin):
fields = ["question", "created_by", "pub_date"]
readonly_fields = ["pub_date"]
def get_queryset(self, request, *args, **kwargs):
queryset = super().get_queryset(request, *args, **kwargs)
tenant = tenant_from_request(request)
queryset = queryset.filter(tenant=tenant)
return queryset
def save_model(self, request, obj, form, change):
tenant = tenant_from_request(request)
obj.tenant = tenant
super().save_model(request, obj, form, change)
With these changes, you have a basic multi-tenant app. But there is a lot more to do as we will see in the following chapters.
The code for this chapter is available at https://github.com/agiliq/building-multi-tenant-applications-with-django/tree/master/shared-db