Custom Managers

Der Manager stellt in Django die zentrale Schnittstelle für Datenbankabfragen im Rahmen des Object-Relational Mappers (ORM) dar. Über ihn werden QuerySets erzeugt und Operationen auf Datenbankebene ausgeführt. Jedes Model besitzt mindestens einen Manager, standardmäßig mit dem Namen objects. Dieser Default-Manager ermöglicht den Zugriff auf alle Datensätze eines Modells, etwa über Event.objects.all().

Darüber hinaus können eigene Manager definiert werden, die den Zugriff auf eine bestimmte Teilmenge der Daten kapseln. So lassen sich häufig benötigte Filterlogiken zentralisieren und wiederverwenden, anstatt sie an mehreren Stellen im Code zu wiederholen.

Das Hinzufügen zusätzlicher Manager ist die bevorzugte Methode, um Modellen tabellenbasierte Abfragen hinzuzufügen. Für Funktionen auf „Zeilenebene“, also solche, die auf eine einzelne Instanz eines Modellobjekts wirken, werden hingegen Modelmethoden verwendet und keine benutzerdefinierten Manager.

Auf diese Weise wird auch das DRY-Prinzip umgesetzt. Generell verfolgen wir in Django häufig den Ansatz fette Models, dünne Views: Geschäftslogik sollte möglichst nah am Model liegen und nicht redundant in verschiedenen Views implementiert werden. Das bedeutet jedoch nicht, dass sämtliche Logik im Model untergebracht werden sollte.

Bei umfangreicher oder komplexer Geschäftslogik ist es sinnvoll, einen separaten Service-Layer einzuführen, der übergreifende Abläufe kapselt und verhindert, dass Models überladen werden oder Verantwortlichkeiten vermischt werden.

Manager, Service-Layer und Architektur

Die Aufteilung von Geschäftslogik in Models, Manager und Service-Layer ist ein komplexes Thema der Softwarearchitektur. In diesem Buch wird darauf nur oberflächlich eingegangen. Eine vertiefte Betrachtung, auch von etwas komplexeren Managern, erfolgt in einem weiterführenden Band.

aktive Events

In unserem Model haben wir einen Flag, den wir bisher noch gar nicht genutzt haben, das Attribut is_active, ein Boolean-Feld, dass angeben soll, ob der Event überhaupt angezeigt werden soll oder nicht. Deaktivierte Events könnten zum Beispiel Stornierungen, Verstöße gegen die Hausregeln oder einfach nur veraltete Events sein.

Wenn wir bisher die Menge aller aktiven Events erhalten wollen, mussten wir wie folgt vorgehen:

>> Event.objects.filter(is_active=True)
<QuerySet [<Event: Another create perhaps visit.>,....

Da wir generell auf unserer Seite nur aktive Events betrachten wollen, müssten wir uns also überall wiederholen. Das würde gegen das DRY-Prinzip verstoßen. Besser ist es also, so eine Aktion zentral zu definieren.

ein Active-Manager

Wir könnten aber auch einen eigenen Manager dafür schreiben! Dazu öffnen wir die Datei event_manager/events/models.py und ändern das Event-Model wie folgt ab:

class Event(DateMixin):

    class Meta:
        ordering = ["name"]
        verbose_name = _("event")
        verbose_name_plural = _("events")
    [..]

    # Diese beiden Zeilen hinzufügen
    objects = models.Manager()
    active = ActiveManager()

Der erste Manager, den wir dem Model hinzufügen, ist der Default-Manager, unabhängig von seinem Namen. Um also den Default-Manager objects nicht zu verlieren, geben wir objects = models.Manager() nochmal explizit hier an.

Nun wollen wir einen eigenen Manager Active-Manager entwickeln. Wir fügen folgende Klasse zu event_manager/events/models.py hinzu. Die Klasse muss oberhalb der Klasse Event definiert werden.

class ActiveManager(models.Manager):
    """Manager mit einem Queryset, das nur aktive Elemente zurückgibt."""
    def get_queryset(self):
        return super().get_queryset().filter(is_active=True)

Die Klasse ActiveManager muss von models.Manager erben, um später auch als Manager nutzbar zu sein. Dann überschreiben wir die Methode get_queryset(), die uns das Queryset des Managers liefert. In unserem Fall also die nach dem Aktiv-Status gefilterten Objekte.

Wie wir sehen können, ist der Manager vom Model, wo er eingesetzt wird, erstmal unabhängig. Er überschreibt lediglich die Methode get_queryset.

Auslagern in eigene Dateien

Wenn man mehrere Manager-Klassen definiert hat, lohnt sich das Auslagern in eine Datei event_manager/events/managers.py. Danach wird dieser Manager in die models.py importiert:

from .managers import ActiveManager

...
active = ActiveManager()

den Manager nutzen

Wir wollen nur noch aktive Elemente erhalten:

active_events = Event.active.all()
print(active_events)

Wir könnten unsere EventListView umbauen und den neuen Manager nutzen, um nur aktive Events anzuzeigen. Dazu öffnen wir die Datei event_manager/events/views.py und ersetzen die EventListView durch folgende Version:

class EventListView(ListView):
    """
    Auflisten aller aktiven Events

    /events
    """

    model = Event
    paginate_by = 10

    def get_queryset(self):
        return Event.active.prefetch_related("category").all()

ein eigener Category-Manager

Wir wollen noch einen Manager entwicklen, der uns alle Kategorien plus die Anzahl an Events jeder Kategorie zurückgibt. Dazu nutzen wir annotations. D.h. wir fügen jeder Ergebniszeile eine SQL-Abfrage ein Feld hinzu, welches ein Aggregat einer anderen Tabelle darstellt. Im konkreten Falls sind wir an der Anzahl der Events pro Kategorie interessiert:

class CategoryManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().annotate(
            number_events=Count('events')
        )

Wir fügen jeder Zeile des Ergebnisses ein neues Feld number_of_events hinzu. Das SQL dazu sieht in etwa so aus:

'SELECT "events_category"."id", "events_category"."created_at",
"events_category"."updated_at", "events_category"."name",
"events_category"."sub_title",
"events_category"."description", COUNT("events_event"."id") AS
"number_of_events" FROM
"events_category" LEFT OUTER JOIN "events_event"
ON ("events_category"."id" = "events_event"."category_id")
GROUP BY "events_category"."id", "events_category"."created_at",
"events_category"."updated_at", "events_category"."name",
"events_category"."sub_title", "events_category"."description"'

und registrieren den Manager in der Klasse Category als Default-Manager:

objects = CategoryManager()

Wir können den Manager wie folgt nutzen:

>>> from events.models import Category
>>> c = Category.objects.first()
>>> c.number_events
6

Wir könnten jetzt im Template beim Anzeigen der Kategorien auch die Anzahl der Events pro Kategorie anzeigen, ohne dass wir dafür extra die Methode number_of_events aufrufen müssen, wie wir das bisher gemacht haben.

Dazu öffnen wir event_manager/events/templates/events/category_list.html und ändern die Kategorie-Liste wie folgt ab:

{% for category in categories %}

<a href="{% url 'events:category-detail' category.pk %}">
<li class="list-group-item rounded">
    {{category}}
    <span class="badge rounded-pill bg-primary">{{category.number_events}}</span>
</li>
</a>
{% endfor %}

Annotations VS. Aggregate

Annotations und Aggregate sind ein gutes Mittel, um die Ausführungsgeschwindigkeit einer website zu optimieren, da die Operationen direkt auf der Datenbank ausgeführt werden, und nicht später im langsamen Python Code.

Aggregate sind zusammenfassende Werte über ein komplettes Queryset. Zum Beispiel die Anzahl aller Events der Kategorien Sport und Bücherwurm.

>>> from django.db.models import Q, Avg, Sum, Min, Max
>>> Category.objects.filter(Q(name="Talk") | Q(name="Sport"))\
>>> .aggregate(anzahl_events=Count('events'))
{anzahl_events: 87}

oder die durchschnittliche min_group aller Events:

>>> Category.objects.all().aggregate(n=Avg('events__min_group'))
{'n': 8.8059701stable53731}

Annotationen hingegen sind Aggregate auf Objekt-Ebene. Das heisst, jedem Eintrag im Queryset wird ein Feld hinzugefügt, zb. jeder Kategorie die durchschnittliche min_group ihrer Events:

>>> categories = Category.objects.annotate(n=Avg('events__min_group'))
>>> categories.first().n
8.095238095238095

oder eine Abfrage, wie viele Events pro Kategorie im Namen die Zeichenkette „in“ haben.

>>> b = Category.objects.annotate(c=Count('events', filter=Q(events__name__contains='in')))
>>> b.first().c
11

Oder eben die Anzahl der Events, die einer Kategorie zugeordnet sind:

>>> cats = Category.objects.annotate(number_events=Count('events'))
>>> cats.first().number_events
11

oder die Mindest-Gruppengröße eines Events innerhalb einer Kategorie:

>>> result = Category.objects.annotate(group_min=Min('events__min_group'))
>>> result[0].group_min
0

Um einen String an ein Varchar-Feld anzuhängen, gibt es die Funktion Concat. Hier im Beispiel hängen wir an jeden Eintrag des sub_title den String -> deprecated an:

from django.db.models.functions import Concat
from django.db.models import Value

Category.objects.update(
  sub_title=Concat("sub_title", Value("-> deprecated"))
)

Mehr zum Thema Annotationen und Aggregate

https://docs.djangoproject.com/en/stable/topics/db/aggregation/