4

I'm building a single database/shared schema multi-tenant application using Django 2.2 and Python 3.7.

I'm attempting to use the new contextvars api to share the tenant state (an Organization) between views.

I'm setting the state in a custom middleware like this:

# tenant_middleware.py

from organization.models import Organization
import contextvars
import tenant.models as tenant_model


tenant = contextvars.ContextVar('tenant', default=None)

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

    def __call__(self, request):
        response = self.get_response(request)
        user = request.user

        if user.is_authenticated:
            organization = Organization.objects.get(organizationuser__is_current_organization=True, organizationuser__user=user)
            tenant_object = tenant_model.Tenant.objects.get(organization=organization)
            tenant.set(tenant_object)

        return response

I'm using this state by having my app's models inherit from a TenantAwareModel like this:

# tenant_models.py

from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
from organization.models import Organization
from tenant_middleware import tenant

User = get_user_model()


class TenantManager(models.Manager):
    def get_queryset(self, *args, **kwargs):
        tenant_object = tenant.get()

        if tenant_object:
            return super(TenantManager, self).get_queryset(*args, **kwargs).filter(tenant=tenant_object)
        else:
            return None

    @receiver(pre_save)
    def pre_save_callback(sender, instance, **kwargs):
        tenant_object = tenant.get()
        instance.tenant = tenant_object


class Tenant(models.Model):
    organization = models.ForeignKey(Organization, null=False, on_delete=models.CASCADE)

    def __str__(self):
        return self.organization.name


class TenantAwareModel(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss')
    objects = models.Manager()
    tenant_objects = TenantManager()

    class Meta:
        abstract = True

In my application the business logic can then retrieve querysets using .tenant_objects... on a model class rather than .objects...

The problem I'm having is that it doesn't always work - specifically in these cases:

  1. In my login view after login() is called, the middleware runs and I can see the tenant is set correctly. When I redirect from my login view to my home view, however, the state is (initially) empty again and seems to get set properly after the home view executes. If I reload the home view, everything works fine.

  2. If I logout and then login again as a different user, the state from the previous user is retained, again until a do a reload of the page. This seems related to the previous issue, as it almost seems like the state is lagging (for lack of a better word).

  3. I use Celery to spin off shared_tasks for processing. I have to manually pass the tenant to these, as they don't pick up the context.

Questions:

  1. Am I doing this correctly?

  2. Do I need to manually reload the state somehow in each module?

Frustrated, as I can find almost no examples of doing this and very little discussion of contextvars. I'm trying to avoid passing the tenant around manually everywhere or using thread.locals.

Thanks.

tunecrew
  • 888
  • 9
  • 13
  • did you make it work with ContextVars? I am tryting to do the same thing ad avoid using thread.locals, but no idea. Other languages/frameworks support accessing the request/user in the model out of the box – Martin Taleski Jun 09 '23 at 16:55
  • 1
    @MartinTaleski yes I did, but a bit rusty since I did this a long time ago. I have middleware that does `tenant = contextvars.ContextVar('tenant', default=None) ` and then `tenant.set` where appropriate. – tunecrew Jun 09 '23 at 21:50

1 Answers1

2

You're only setting the context after the response has been generated. That means it will always lag. You probably want to set it before, then check after if the user has changed.

Note though that I'm not really sure this will ever work exactly how you want. Context vars are by definition local; but in an environment like Django you can never guarantee that consecutive requests from the same user will be served by the same server process, and similarly one process can serve requests from multiple users. Plus, as you've noted, Celery is a yet another separate process again, which won't share the context.

Daniel Roseman
  • 588,541
  • 66
  • 880
  • 895
  • Thanks - to your first answer, makes total sense wasn’t clear if the context setting was independent of the request/response mechanism - will try that and see if it works as intended. – tunecrew Aug 07 '19 at 15:56
  • Re your second answer, you may be right but I’m going to test once I’ve fixed the first issue. I’ve read some other suggestions that it can be used this way but they were very light on details so we shall see. – tunecrew Aug 07 '19 at 16:00
  • Re. Celery - so I’m currently manually passing the tenant to the new task, which then creates a new context using this tenant. Was hoping to find a less manual way but not sure of what direction to go. – tunecrew Aug 07 '19 at 16:02
  • were you able to figure this out? – kevthanewversi Jan 30 '22 at 16:24
  • 1
    For Celery you could take a similar approach as you have with the Django models, i.e. use a base class for the task that always adds the tenant: https://docs.celeryproject.org/en/stable/userguide/tasks.html#task-inheritance – Sakari Cajanus Feb 19 '22 at 06:53