Multiple Aktionen in Djangos Administration

Wenn du Djangos Administrationsoberfläche nutzt, wirst du dich vielleicht schon einmal geärgert haben, dass man für jede Aktion eines Element erst das Element selbst aufrufen muss. Beispiel: Möchtest du einen Beitrag löschen, musst du ihn anklicken und in der Detailansicht unten links auf den "Löschen" Button klicken.

Nicht weiter tragisch, spätestens wenn du aber 10 oder mehr Beiträge löschen willst, (Stichwort: Spam-Kommentare) wird dich dieses Verhalten nerven.

Dieser Beitrag soll einen Ansatz zeigen, Aktionen auf multiple Elemente anzuwenden.

Das Minimal-Model

Wie schon im letzten Beispiel beginne ich wieder mit dem Minimal-Model. In diesem Fall ist es ein super-simples Weblog.

# -*- coding: utf-8 -*-
from django.db import models

class Entry(models.Model):
    title = models.CharField('Titel', max_length=120)
    content = models.TextField('Inhalt')
    enabled = models.BooleanField('Aktiv', default=False)

    class Admin:
        list_display = (
            'enabled',
            'title',
        )

        list_display_links = (
            'title',
        )

    def __unicode__(self):
        return str(self.id)

Mit ein paar Daten schaut die Listenansicht so aus:

Ordnerstruktur

Das Ziel diesmal ist es, vor jeden Beitrag eine Checkbox zu setzen, später sollen alle diese markierten Beiträge mit einem Klick gelöscht werden.

Die Checkbox

Wie in Vorschaubilder in Djangos Administration gezeigt, lassen sich zusätzliche Spalten frei definieren. Ich spare mir diesmal die Erklärungen dazu, wer es nicht nachvollziehen kann, möchte sich bitte den o.g. Beitrag noch einmal anschauen.

class Entry(models.Model):

    # ...

    class Admin:
        list_display = (
            'mass_deletion_checkbox',
            'enabled',
            'title',
        )

    # ...

def mass_deletion_checkbox(self):
    return '<input type="checkbox" name="delete" value="%s"/>' % self.id
mass_deletion_checkbox.short_description = 'Löschen'
mass_deletion_checkbox.allow_tags = True

Im Webbrowser sieht das Ganz nun so aus.

Admin Änderungsliste

Ganz nett, leider aber ohne jegliche Funktion. Es fehlt ein Formular-Element drumherum, welches auf unsere Aktion (den View) zeigt sowie natürlich der Submit-Button selbst.

Die Admin-Templates

Djangos Administration ist ein App wie du es auch erstellst, ohne Frage wirkt es in vielen Dingen magisch und dennoch ist es nur eine Ansammlung aus Views, Models und Templates.

Um unser Formular-Element um die Checkboxen zu bringen, müssen wir ein Admin-Template ändern, genauer gesagt das Template:

/path/to/django/contrib/admin/templates/admin/change_list.html

Der aufmerksame Leser wird nun aufschreien, so ist es doch keine gute Idee den Quellcode direkt in Django zu ändern. Spätestens mit dem nächsten Update sind die Änderungen hinüber -- oder wenn man aus dem SVN-Trunk arbeitet, hinterlassen die Änderungen vielleicht inkonsistente Zusammenhänge.

Das haben sich auch die Entwickler der Administrationsoberfläche gedacht und deshalb ist die Lösung sehr einfach. Jedes Template im oben genannten Pfad kann mit deinem eigenen überschrieben werden. Lege dazu in deinem Template-Ordner (siehe settings.py, die Tupel TEMPLATE_DIRS) einen Ordner "admin" an und kopiere dorthin das change_list.html Template.

Wie funktioniert das? Grob gesagt schaut die Administrations-Applikation zuerst in seinem Template-Ordner, wenn aber in deinen Templates ein gleichnamiges Template liegt, wird dieses bevorzugt. (Oder umgekehrt... ;-))

Nun möchten wir ja nicht in jedes unserer Apps diese Checkbox-Aktionen einbauen -- auch daran wurde gedacht. Man kann für jede Applikation eigene Templates anlegen. Dazu wird es einfach in den Ordner /templates/admin/«appname»/«modelname»/ verschoben. Bei diesem Beispiel also der Ordner /templates/admin/weblog/entry/.

Die Ordnerstruktur sieht ungefähr so aus:

Ordnerstruktur

Noch ein Hinweis zum Thema Template-Überschreibung

Oben erwähnte ich, dass man jedes Admin-Template überschreiben kann. Das ist wohl so nicht richtig. Im Djangobook wird beschrieben, dass sich nur die Templates:

automatisch überschreiben lassen. Ob dieses Verhalten so gewollt ist oder es sich bis zur Version 1.0 noch ändert, kann ich nicht sagen. In unserem Beispiel spielt es aber keine Rolle.

Zurück zum Template

Die Änderungen im Template sind, wie gesagt, ein Formular-Element um die Ergebnisse zu legen und einen Submit-Button hinzuzufügen. Schau dir das Template aufmerksam an und ändere dann die Zeile:

{% block result_list %}{% result_list cl %}{% endblock %}

so ab:

<form method="post" 
      action="/admin/massdelete/{{ cl.opts.app_label }}/{{ cl.opts.module_name }}/" 
      onsubmit="return confirm('Möchtest du die markierten Beiträge löschen?');"
>
{% block result_list %}{% result_list cl %}{% endblock %}
<p><input type="submit" value="Markierte Beiträge löschen"/></p>
</form>

Der Pfad /admin/massdelete/{{ cl.opts.app_label }}/{{ cl.opts.module_name }}/ ist die URL zu unserem View, der später die entsprechenden Beiträge löscht. In der Ausgabe wird daraus /admin/massdelete/weblog/entry/.

Wenn du dich fragst, was {{ cl.opts }} ist, dann ist die Antwort einfach. Diese Variable enthält alle Meta-Informationen deiner Applikation. Eine Liste aller Inhalte erhälst du einfach über die Shell:

martin@pixelbox:~/Projekte/my_project$ ./manage.py shell --plain
&gt;&gt;&gt; from my_project.weblog.models import Entry
&gt;&gt;&gt; e = Entry
&gt;&gt;&gt; dir(e._meta)
['add_field', 'admin', 'app_label', 'auto_field', 'contribute_to_class', ...]

Natürlich könnten wir auch gleich den festen Pfad /admin/mass-delete-entries/weblog/entry/ setzen, so lässt sich das Verfahren aber einfacher auf andere Apps übertragen. (Stichwort: DRY)

Wiederum schaut die Ausgabe nun in etwa so aus:

Admin Änderungsliste

Mach was!

Nun fehlt zu guter Letzt noch unser View, der die angeklickten Einträge löscht und wieder zurück auf die Admin-Liste springt. Lass uns aber zuerst die URL dafür festlegen. Ändere deine urls.py wie folgt ab:

urlpatterns = patterns('',
    (r'^admin/massdelete/(?P<appname>[_\w]+)/(?P<modelname>[_\w]+)/$', 'my_project.weblog.views.massdelete'),
    (r'^admin/', include('django.contrib.admin.urls')),
)

Wichtig ist hierbei, dass unser View über den eigentlichen Admin-Views steht, damit gesichert ist, dass er auch definitiv zuerst ausgeführt wird.

Der View zum löschen der Beiträge schaut bei mir ungefähr so aus:

# -*- coding: utf-8 -*-

from django.http import HttpResponseRedirect
from django.db import models
from django.shortcuts import get_object_or_404
from django.contrib.auth.decorators import permission_required
from my_project.weblog.models import Entry

@permission_required('weblog.delete_entry',) 
def massdelete(request, appname, modelname):

    app_model = models.get_model(appname, modelname)
    ids_to_delete = request.POST.getlist('delete')

    if len(ids_to_delete) &gt; 0:
        for id in ids_to_delete:
            entry = get_object_or_404(app_model, pk=id)
            entry.delete() # Hier wird der Beitrag gelöscht
        user_message = '%s Beiträge wurden gelöscht.' % len(ids_to_delete)
    else:
        user_message = 'Keine Beiträge wurden gelöscht, da keine übergeben wurden.'

    # Eine Nachricht an den User senden
    request.user.message_set.create(message=user_message)

    # Zurück zur Liste springen
    return HttpResponseRedirect('/admin/%s/%s/' % (appname, modelname))

Aus dem Formular werden die Werte der Checkboxen (die Beitrag-IDs) mittels request.POST.getlist in eine Tupel übertragen. Über diese Tupel wird nun iteriert und der Beitrag mit der entsprechenden ID wird gelöscht.

Datenbank-Spezialisten werden nun aufschreien, wird mit diesem Verfahren doch für jede Löschung ein eigener Query an die Datenbank geschickt. Der Einfachheit halber finde ich das hier so in Ordnung. Wer mehr Wert auf Performance legt, kann sich auch ein eigenes SQL-Statement basteln und ihn über die Low-Level Datenbank-Api absetzen.

Spassenshalber habe ich ein Video erstellt, um das Endresultat zu verdeutlichen.