Automatischer und zuverlässiger Umgang mit Besitzrechten von django-Objekten

Ein Problem, welches man in (fast) jedem Projekt früher oder später lösen muss, ist der "Besitz" von Datenbankobjekten.

Ronny Vedrilla
Ronny Vedrilla
, 09. Jul 2020

Vorwort

Wer lediglich auf der Suche nach einer Lösung für dieses Problem ist, kann gerne die Prosa überspringen und sich den letzten Abschnitt des Artikels anschauen.

Erste Schritte

Seit 2012 arbeite ich an großen und kleinen django-Projekten. Ein Problem, welches man in (fast) jedem Projekt früher oder später lösen muss, ist der „Besitz“ von Datenbankobjekten.

Entweder aus Gründen der Sichtbarkeit, Berechtigungen oder einer Mandantenfähigkeit ist es enorm wichtig, den Ersteller eines Objektes korrekt zu erfassen. Ein typisches Vorgehen ist, im Objekt per Fremdschlüsselbeziehung den aktuellen Benutzer zu speichern:

class MyFancyModel(models.Model):
     ...     
     created_by = models.ForeignKey(settings.AUTH_USER_MODEL,    
                                    verbose_name="Created by", 
                                    related_name="pages",  
                                    on_delete=models.CASCADE)

Der Zeitstempel (Erstellungs- und letztes Änderungsdatum) ist ebenfalls eine wertvolle Information, welche sehr einfach zu bekommen und zu speichern ist. Aus diesem Grund verwenden wir meist eine abstrakte Klasse, welche all diese Informationen enthält:

from django.conf import settings

class CommonInfo(models.Model):    
    created_at = models.DateTimeField(
        'Created at',
        auto_now_add=True,
        db_index=True)    
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name='Created by',
        blank=True, null=True,
        related_name="%(app_label)s_%(class)s_created",
        on_delete=models.SET_NULL)    
    lastmodified_at = models.DateTimeField(
        'Last modified at',
        auto_now=True,
        db_index=True)    
    lastmodified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name='Last modified by',
        blank=True, null=True,
        related_name="%(app_label)s_%(class)s_lastmodified",
        on_delete=models.SET_NULL)
    
    class Meta:
        abstract = True

Jetzt muss "nur noch" der Ersteller eines Objektes zuverlässig gespeichert werden. Leider ist dies einfacher gesagt als getan.

Der django'eske Weg um sicherzustellen, dass Informationen gespeichert werden, die nicht zwingend direkt über ein Formular oder einen Serializer kommen, wäre, die "save"-Methode des Models zu überschreiben. In den allermeisten Fällen ist allerdings der aktuelle Request-User der Ersteller. Django selbst schleift den Request nicht auf Modelebene durch aufgrund des Prinzips "separation of concerns". Würden wir nun den Request manuell in die Save-Methode übergeben, brächen wir mit einem bewährten Verfahren und würden zusätzlich ein ziemliches Chaos im Projekt verursachen.

Ein alternativer Ansatz wäre es, manuell eine Funktion, welche die benötigten Attribute setzt, in jedem View und Formular einzubauen. Die Nachteile sind offensichtlich: Man muss immer dran denken, es an jeder Stelle einzubauen. Vergisst man (oder der Neue im Team) es auch nur an einer Stelle, kann dies zu schwerwiegende Dateninkonsistenzen führen und darüber hinaus kann es sein, dass ein solcher Bug lange unbemerkt bleibt. Damit ist klar, dass dies keinen pragmatischer Weg darstellt.

Glücklicherweise gibt es bei django eine eingebaute Lösung, die uns bei diesem Problem helfen kann. Das Middleware-Konzept erlaubt es uns, Requests abzufangen und beliebig zu manipulieren. Allerdings muss man hierbei beachten, dass Middlewares nicht standardmäßig Thread-sicher sind. Das bedeutet, dass ab und zu der aktuelle Request-User mit einem anderen, aktiven Benutzer überschrieben wird, was zu falschen Objektbesitzern führt - und das in einem Szenario, welches praktisch nicht reproduzierbar ist. Das ist etwas, dass jeder Entwickler unbedingt vermeiden will - glaubt uns, wir waren schon an diesem Punkt!

Deshalb haben wir eine Middleware implementiert, welche verschiedene Ansätze aus dem Internet vereint um eine funktionierende, getestete und moderne Lösung für django > 2.x zu liefern.

Unsere Middleware aktualisiert bei jedem Request eine Thread-sichere variable "_user" mit dem aktuellen Userobjekt:

# Global thread-safe variable
_user = local()
class CurrentUserMiddleware:
    """
    Middleware which stores request's user into global thread-safe 
    variable.    
    """
    def process_request(self, request):
        _user.value = request.user
    @staticmethod
    def get_current_user():
        if hasattr(_user, 'value') and _user.value:
            return _user.value

Im nächsten Schritt überschreiben wir die Speichern-Methode in der abstrakten Klasse (s.o.) um die relevanten Attribute innerhalb des Objektes mit dem Request-User zu aktualisieren:

class CommonInfo(models.Model):
    ...    
@staticmethod
    def get_current_user():
        return CurrentUserMiddleware.get_current_user()
    def set_user_fields(self, user):
        """
        Set user-related fields before saving the instance.
        If no user with a primary key is given the fields are not 
        set.
        """
        if user and user.pk:
            if not self.pk:
                self.created_by = user
            self.lastmodified_by = user
    def save(self, *args, **kwargs):
        self.lastmodified_at = now()
        current_user = self.get_current_user()
        self.set_user_fields(current_user)
        super(CommonInfo, self).save(*args, **kwargs)

Wer nur die Lösung sucht…

Wir haben das oben Gezeigte in unsere Toolbox ai-django-core eingebaut. Diese lässt sich ganz einfach mit pip installieren:

pip install ai-django-core

Oder auch so, wir leben ja schließlich schon im zweiten Jahrzehnt nach der Jahrtausendwende:

pipenv install ai-django-core

Als nächstes muss unsere Middleware in die "settings.py" eingebunden werden. Aus offensichtlichen Gründen nach der „AuthenticationMiddleware“:

MIDDLEWARE = (
    ...
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'ai.middleware.current_user.CurrentUserMiddleware',
)

Nun muss nur noch das Model von unserer abstrakten Klasse "CommonInfo" abgeleitet werden:

class MyNewFancyModel(CommonInfo):
     ...

Fertig.

Abschließende Worte

Ein besondere Dank gebührt Denis Anuschewski, der diese Lösung gefunden, getestet und in unserem Core-Paket implementiert hat.