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.