Using the Django shell with django-scopes

If you have a project where multiple tenants/users/groups share the same database, but should never interfere with one another, you're going to worry about making sure to prevent data leaks. I know, because I run this kind of setup with multiple projects, most notably pretalx, which uses Django.

As one measure of separating tenants, pretalx uses the new and wonderful django-scopes, which makes sure that models identified as scoped are only accessed in an active scope context. If this sounds interesting to you, please read the blog post introducing django-scopes – it explains the principles and limitations very well.

We started using django-scopes with pretalx, and we were pretty happy. Then, one day, I needed to look up some data that is not directly exposed in the frontend, for debugging purposes. In Django, you'd usually run myproject shell (or myproject shell_plus if you use the excellent django_extensions library), which will spawn a Python REPL which has loaded the Django settings, configured database connections and the like, and gives you access to your models.

Naturally, any database access via the Python shell is also blocked by django-scopes if it's not run inside a scope. But that's really annoying! Where you'd usually run

In [1]: event.submissions.filter().count()
Out[1]: 3

you now have to run:

In [1]: with scope(event=event):
   ...:     print(event.submissions.filter().count())
   ...:
Out[1]: 3

This adds both two lines of input, and a print statement. This may not seem much, but will get annoying soon when you're stuck debugging for some length of time, especially since your feedback loop has gone from "press enter, see result" to "don't forget to print(), and press enter twice".

shell_scoped

pretalx now has its own command to start a shell, called shell_scoped. You can call it either with a model lookup, or with a --disabled flag to allow access to all events:

myproject shell_scoped --event=3
myproject shell_scoped --event__slug=myevent
myproject shell_scoped --disabled

This code works for arbitrary models and attributes, as long as your scope has the same name as your model. This is a minimal version of the Command class (and you can find the up-to-date full version with complete error handling here.

import argparse
import sys

from django.apps import apps
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django_scopes import scope, scopes_disabled


class Command(BaseCommand):
    def create_parser(self, *args, **kwargs):
        parser = super().create_parser(*args, **kwargs)
        parser.parse_args = lambda x: parser.parse_known_args(x)[0]
        return parser

    def handle(self, *args, **options):
        parser = self.create_parser(sys.argv[0], sys.argv[1])
        flags = parser.parse_known_args(sys.argv[2:])[1]
        if "--override" in flags:
            with scopes_disabled():
                return call_command("shell", *args, **options)

        lookups = {}
        for flag in flags:
            lookup, value = flag.lstrip("-").split("=")
            lookup = lookup.split("__", maxsplit=1)
            lookups[lookup[0]] = {
                lookup[1] if len(lookup) > 1 else "pk": value
            }
        models = {
            model_name.split(".")[-1]: model_class
            for app_name, app_content in apps.all_models.items()
            for (model_name, model_class) in app_content.items()
        }
        scope_options = {
            app_name: models[app_name].objects.get(**app_value)
            for app_name, app_value in lookups.items()
        }
        with scope(**scope_options):
            return call_command("shell", *args, **options)

Some parts of this are a bit hacky and/or brittle. If you have multiple models with the same class name, for instance, this will not work for you, and you'll want to modify it to use a dotted path to your model.

We're also monkeypatching the parse_args method of the argument parser to run parse_known_args instead, so that the parser will not raise an exception when it encounters unconfigured arguments. Since we allow arbitrary queries, we can't feasibly just build a list with all available models beforehand for the parser, and Django's Command class has no way of configuring the parser in ways that are useful to us.

So that's it! A nice, flexible command to initiate scoped sessions – and a full working example of specifying arbitrary model instances via command line arguments in Django commands, to boot.