Custom "per-tenant" views in Django

Hi.
I developed a multi-tenant Django app (single DB with single schema and domain-based, eg. https://tenant-xyz.esample.com) and I’d like to be able to customize some views according to the current tenant. For example, /my_app/some_view might include some logic while /my_app/tenant_xyz/some_view might behave in a different way. Of course I could also have /my_app/tenant_abcdef/some_unique_view_for_tenant_abcdef_only.

Following Django’s urls.py phylosophy, I guess custom views could be enclosed in their own views_tenant_xyz.py and tenant-related URLs would point to custom views files.

The simplest intuition was to to dynamcally include tenants’ custom views URLs in urls.py (without typing them statically).

Given a urls.py fragment:

urlpatterns = [
    path('', include('my_app.urls')),
    path('admin/', admin.site.urls),
]

I thougth I could include the following code in urls.py (here tenant.name is a sort of slug):

for tenant in Tenant.objects.all():
    urlpatterns += [
        path(f'custom_views/{tenant.name}/', include(f'myapp.tenants.urls_{tenant.name}', namespace=f'customviews{tenant.name}')),
        ]

and then define custom CVB (or functions) in each myapp.tenants.urls_{tenant.name}.py but I get an "SynchronousOnlyOperation at / You cannot call this from an async context - use a thread or sync_to_async." (I’m running ASGI with Gunicorn + Uvicorn workers).

Btw I suppose using ORM inside settings.py is not the best approach. What would you advise me to do?
Maybe I’d better using my multi-tenant middleware to do something like that?

class SetTenantMiddleware:
    """
    Middleware to handle multi-tenancy.
    Extract tenant from request.
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tenant = tenant_from_request(request)
        set_current_tenant(tenant)
        request.tenant = tenant
     
        if tenant:
              tenant_slug = tenant.name
              tenant_custom_url_file = f'my_app/urls_{tenant_slug}.py'
              tenant_custom_view_file = f'my_app/views_{tenant_slug}.py'

              if os.path.isfile(tenant_custom_url_file) and os.path.isfile(tenant_custom_view_file):
                  request.urlconf = f'my_app.urls_{tenant_slug}'

        response = self.get_response(request)
        return response

Thanks in advance.

Hello there!

You’re receiving this error because you’re running this query from an async environment.

When that’s the case you should use the sync_to_async decorator.
But you’re doing this at the module level, you don’t want to do this.

I think that the better place to put this piece of logic is on the AppConfig.ready method.
This will run once when your application is loaded, that’s different from the middleware one that will run on every single request, and may not even run… Because i don’t think that this middleware will be reached if there’s no URLPattern matching the request path (this would happen when the request path is for a custom url that has not yet been defined).

If you manage to make it work using the AppConfig.ready method let me know.

Thank you leandro!
Could you give me another hint? I don’t understand how can I add custom urls at AppConfig level:

from my_app import urls
from .models import Tenant

class MyAppConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'my_app'

    @sync_to_async
    def get_all_tenants(self):
        return Tenant.objects.all()

    def ready(self):
        tenants = self.get_all_tenants()
        for tenant in await tenants:    
            urlpatterns += [
        path(f'custom_views/{tenant.name}/', include(f'myapp.tenants.urls_{tenant.name}', namespace=f'customviews{tenant.name}')),
        ]

urlpatterns is a variable inside the some_app.urls and it’s a list.
So you can append or plus add them to the urlpatterns.

I tried to do as you told me:

class MyAppConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'my_app'
    
    # Tenants-related custom URLs
    tenants_custom_urlpatterns = []

    def ready(self):
        from .models import Tenant
        
        # --------------------------------- #
        # PER-TENANT CUSTOM URLS AND VIEWS  #
        # --------------------------------- #
        tenants = Tenant.objects.all()
        for tenant in tenants:
            tenant_slug = tenant.name
            custom_url_file = os.path.join(BASE_DIR, f'my_app/tenants/urls_{tenant_slug}.py')
            custom_view_file = os.path.join(BASE_DIR, f'my_app/tenants/views_{tenant_slug}.py')
            if os.path.isfile(custom_url_file) and os.path.isfile(custom_view_file):
                logger.info(f'Discovered custom views for tenant {tenant_slug} (adding to standard views)')  # log added tenant custom views
                self.tenants_custom_urlpatterns += [
                    path(f'custom_views/{tenant_slug}/', include((f'my_app.tenants.urls_{tenant_slug}', f'custom_views_{tenant_slug}'), namespace=f'custom_views_{tenant_slug}')),
                ]
        
        # Merge custom URLs with
        from MyApp.urls import urlpatterns
        if len(self.tenants_custom_urlpatterns) > 0:
            urlpatterns += self.tenants_custom_urlpatterns
            logger.info(f'Custom URLs merged with standard views')

At first seems to work but after some minutes I get timeouts, due to this error:

# OperationalError at /dashboard/
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
...

until I see:

|args|(<DatabaseWrapper vendor='postgresql' alias='default'>,)|
|---|---|
|func|<function BaseDatabaseWrapper.connect at 0x7fa57a8b5300>|
|kwargs|{}|
|message|'You cannot call this from an async context - use a thread or sync_to_async.'|

I think usign ORM inside AppConfig (in an ASGI context) causes issues, blocking the main loop.

If I try to use:

@sync_to_async
def get_all_tenants(self):
   return Tenant.objects.all()

and then

def ready(self):
    tenants = self.get_all_tenants()
    for tenant in *await* tenants:

I get another error:
RuntimeError: Model class django.contrib.contenttypes.models.ContentType doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS.

I really don’t know what to do.

Using sync_to_async is going to be required, you’re on the right path.

About this:

Can you confirm that you have django.contrib.contenttypes on your INSTALLED_APPS setting?

Yes, I confrim. In the end I solved it in another way (via settings.py), though I’m sorry I didn’t understand the reason for the error.
Thanks for your support Leandro.

1 Like