Schönere Formulare mit Crispy Forms

Ein fast unverzichtbares Package in Django ist Crispy Forms, vor allem, wenn man an schönem Formular Layout interessiert ist. Die Installation hatten wir schon in einem früheren Kapitel beschrieben. In diesem Kapitel wollen wir das Package näher beleuchten und uns ein paar Features ansehen.

Template Packs

Vorab: Wir hatten Crispy Forms bereits mit dem Template Pack für das Bootstrap-Framework genutzt, da wir in diesem Projekt auch auf Bootstrap5 setzen. Aber es werden von Haus aus noch viele andere CSS-Frameworks unterstützt, zum Beispiel das modernere Bulma oder TailwindCSS. django-crispy-forms lässt sich also in fast jedes CSS-Framework einbinden.

Eine Übersicht der Template Packs, die Crispy Forms bietet, findet sich in der Doku: https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs

Der Crispy Template-Filter

Bisher hatten wir Crispy Forms in einer Variante kennengelernt, die dem Package eigentlich nicht gerecht wird, aber trotzdem schon mächtig genug war, die Darstellung des Formulars deutlich zu verbessern: der Crispy Template Filter.

Zur Erinnerung, so in etwa sah das aus:

{% load crispy_forms_tags %}

 <form method="post">
     {{ form|crispy }}
 </form>

Wir nutzen bisher das Pipe-Symbol, um die form-Variable an den Filter crispy zu übergeben. Crisyp Forms kann natürlich deutlich mehr.

Der Crispy Formhelper

Die FormHelper-Klasse gibt uns eine pythonische Möglichkeit an die Hand, das Layout und die Eigenschaften unserer Formularer so anzupassen, wie wir das wünschen. Und zwar ohne viel HTML-Code direkt in die Templates schreiben zu müssen.

Öffnen wir nun die event_manager/events/forms.py und passen das Formular für das Event-Model etwas an.

from django import forms
from crispy_forms.helper import FormHelper

class EventForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()

    class Meta:
        model = Event
        exclude = ("category", "author")

Den FormHelper haben wir aus crispy_forms.helper importiert.

Der FormHelper in Action

Um nun eine Instanz der FormHelper-Klasse zu erstellen, mussten wir die __init__ Methode überschrieben, damit erstmal die __init__ Methode der Elternklasse ausgeführt werden kann. In unserer überschriebenen __init__ erstellen wir mit self.helper = FormHelper() eine Instanz des FormHelpers.

Mit dem FormHelper können wir zum Beispiel die CSS-Klasse des Formulars definieren, welches später im Template gerendert wird, ohne in das HTML händisch eingreifen zu müssen. Den Submit-Button müssen wir nicht -wie vorher- im Formular angeben, sondern machen das bequem mit add_input von hier aus.

Zusätzlich haben wir noch die HTTP-Methode im Formular auf POST gesetzt, wie das für Web-Formulare üblich ist:

[..]
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit

class EventForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        [..]
        self.helper = FormHelper()
        self.helper.form_method = 'POST'
        self.helper.form_id = 'event_form'
        self.helper.add_input(Submit('submit', 'Formular absenden'))

Darüberhinaus wird uns auch der HTML form-Tag gerendert. Um den Form-Helper nun zu nutzen, müssen wir das Template für das Event-Formular anpassen.

Den FormHelper im Formular nutzen

Um den Formhelper jetzt zu nutzen, öffnen wir die Template-Datei events/templates/events/event_form.html und tragen den folgenden Code ein:

{% extends 'base.html' %}
{% load crispy_forms_tags %}

{% block title %}
{% if object %}
    Edit {{object.name}}
{% else %}
    Event eintragen
{% endif %}
{%endblock%}

{% block head %}
{% if object %}
    <p>
    <a href="{% url 'events:event_detail' object.pk %}">zurück zum Event</a><br>
    </p>

    <h1 class="display-6 fw-bold lh-1">{{object.name}}</h1>
    
{% else %}
    <p>
    <a href="{% url 'events:events' %}">zurück zur Übersicht</a><br>
    </p>

    <h1 class="display-6 fw-bold lh-1">Event eintragen</h1>
    
{% endif %}
{%endblock%}

{% block content %}
<div class="container">
<div class="row">
<div class="col-lg-8 col-md-12 mb-5">

{% crispy form form.helper %}
        
<div>
</div>
</div>
{% endblock %}

Wie wir sehen können, wurde das Template deutlich entschlackt. Alle HTML-Tags, die wir vorher manuell in das Template geschrieben haben wie zum Beispiel <form> oder <button> werden jetzt von Crispy Forms bzw. dem Formhelper erstellt.

Das Formular sollte jetzt schön gerendert sein. Wenn wir uns den Quelltext des Formulars im Browser ansehen (rechter Mausklick, Quelltext ansehen), sehen wir wunderschön das Formular mit CSRF-Feld, Submit-Button und den öffnenden und schließenden Formular-Tags.

Die Einstellungen für Crispy Forms sind vielfältig. Wer mehr wissen möchte, sehe sich bitte zuerst die Doku an:

https://django-crispy-forms.readthedocs.io/en/latest/form_helper.html

Form Layouts

Mit dem FormHelper ist es möglich, das Layout von Formularen einfach zu verändern, ohne mühselig im HTML alles neu zu stylen. Wenn wir zum Beispiel ein zweispaltiges horizontales Layout wollten, in dem links das Label und rechts die Felder stehen, würden wir unseren FormHelper um ein Layout erweitern:

[..]
from crispy_forms.layout import Submit, Layout

class EventForm(forms.ModelForm):

    [..]

    def __init__(self, *args, **kwargs):

        [..]

        # horizontales Formular mit zwei Spalten definieren.
        # Einer Label-Spalte und einer Feld-Spalte
        self.helper.form_class = 'form-horizontal'
        self.helper.label_class = 'col-lg-2'
        self.helper.field_class = 'col-lg-8'
        self.helper.layout = Layout(
            'name',
            'description',
        )

Nun wird ein zweispaltiges Formular mit der Bootstrap-Klasse form-horizontal gerendert, welches sich im Fall von sehr kleinen Endgeräten responsive verhält:

../_images/event_form_5.png

Zum Thema Layouts gibt es noch vieles mehr zu sagen und viele Beispiele finden sich auch in der Doku.

https://django-crispy-forms.readthedocs.io/en/latest/layouts.html

Fieldsets in der Admin-Oberfläche

Bei komplexen Formularen kann es sinnvoll sein, diese in verschiedene Abschnitte einzuteilen. HTML bietet ein fieldset - Element an, mit dem sich Formulare strukturieren lassen.

Django macht das schon von Haus aus in der Adminoberfläche. Dort gibt es die admin.fieldsets, die dazu dienen, Formulare semantisch zu strukturieren.

Hier mal ein Beispiel, wie Django das User-Model in der Admin-Oberfläche darstellt. Die Einteilung der Formularbereiche sind die fieldsets.

../_images/admin_fieldsets.png

Wenn wir unser Event-Model etwas anders strukturieren wollten, könnten wir das in der event_manager/events/admin.py wie folgt festlegen:

@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
    search_fields = ["name"]
    list_filter = ("category",)
    list_display = "author", "name", "category", "is_active"

    fieldsets = (
        ("Standard info", {"fields": ("name", "date", "category")}),
        (
            "Detail Infos",
            {"fields": ("description", "min_group", "sub_title", "is_active")},
        ),
    )

Wir haben zwei Fieldsets angelegt, die das Formular in zwei Bereiche unterteilt: die Standard Infos und die Detail-Infos.

../_images/event_admin_form_2.png

Fieldsets in gewöhnlichen Formularen

Leider bietet Django aber für normale Formulare keine Möglichkeit an, Formulare mit fieldsets unkompliziert zu strukturieren. Man müsste sich die einzelnen Formularfelder rendern lassen und das Formular selber nachbauen. Eine aufwändige und fehleranfällige Arbeit.

Hier kommt uns django-crispy-forms zur Rettung!

Crispy-Forms ermöglicht beim Definieren eines Layouts sogenannte Fieldset. Damit lassen sich zusammengehörige Bereiche auch in normalen Formularen sinnvoll abtrennen. Das erste Argument für die Fieldset-Klasse ist der Name des Bereichs, der als Label gerendert wird. Im Beispiel steht hier dann Standard Infos oder Detail Infos. Die weiteren Argumente wie name oder date beziehen sich hier im Beispiel auf die Attribute des Event-Models.

[..]
from crispy_forms.layout import Submit, Layout, Fieldset, HTML

class EventForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):

        [..]
        self.helper.layout = Layout(
            Fieldset(
                "Standard Infos",
                "name",
                "date",
                "category",
                css_class="form-group",
            ),
            Fieldset(
                "Detail Infos",
                "description",
                "min_group",
                "sub_title",
                "is_active",
                HTML(
                    """
                    <div class='mb-3 row'>
                    <div class='col-lg-2'></div>
                    <div class='col-lg-8'>Danke für die Mitarbeit, <strong>Wir
                    finden das ganz toll!</strong></div>
                    </div>
                    """
                ),
                css_class="form-group",
            ),
        )

Wir haben jetzt auch hier das Formular in zwei Bereich geteilt und zudem jedem Formset noch die Bootstrap-Klasse form-group zugeteilt.

Hier wurde jetzt noch das mächtige HTML-Element genutzt, dass es uns erlaubt, beliebigen HTML-Code in das Formular zu schleusen.

../_images/event_form_6.png

Unser fertiges Event-Form sieht nun so aus:

from django import forms
from django.core.exceptions import ValidationError
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Fieldset, HTML
from .models import Category, Event


class CategoryForm(forms.ModelForm):
    class Meta:
        model = Category
        fields = "__all__"


class EventForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.form_method = "POST"
        self.helper.form_id = "event_form"
        self.helper.form_class = "form-horizontal"
        self.helper.label_class = "col-lg-2"
        self.helper.field_class = "col-lg-8"
        self.helper.add_input(Submit("submit", "Submit"))

        self.helper.layout = Layout(
            Fieldset(
                "Standard Infos",
                "name",
                "date",
                "category",
                css_class="form-group",
            ),
            Fieldset(
                "Detail Infos",
                "description",
                "min_group",
                "sub_title",
                "is_active",
                HTML(
                    """
                    <div class='mb-3 row'>
                    <div class='col-lg-2'></div>
                    <div class='col-lg-8'>Danke für die Mitarbeit, <strong>Wir
                    finden das ganz toll!</strong></div>
                    </div>
                    """
                ),
                css_class="form-group",
            ),
        )

    class Meta:
        model = Event
        fields = "__all__"
        exclude = ("category", "author")

        widgets = {
            "date": forms.DateInput(
                format=("%Y-%m-%d %H:%M"), attrs={"type": "datetime-local"}
            ),
        }

        labels = {
            "name": "Name des Events",
            "sub_title": "Slogan",
            "description": "Kurzbeschreibung",
            "date": "Datum des Events",
        }

    def clean_sub_title(self) -> str:
        sub_title = self.cleaned_data["sub_title"]
        illegal = ("*", "-", ":")
        if isinstance(sub_title, str) and sub_title.startswith(illegal):
            raise ValidationError("Dieses Zeichen ist nicht erlaubt!")

        return sub_title

Den Kategorienamen ausgeben

Einen kleinen Schönheitsfehler hat unser Event-Eintragen Formular aber noch: der Name der Kategorie, für die das Event eingetragen wird, wird an keiner Stelle im Formular erwähnt. Für den User ist das ungünstig, weil er hier im Unklaren gelassen wird, für welche Kategorie er einen Event einträgt.

Um diese Aufgabe zu Bewerkstelligen, muss unsere EventCreateView angepasst werden. Sie muss im Kontext das Category-Objekt mit an das Template übergeben, damit diese Information beim Eintragen darsgestellt werden kann.

Dafür überschreiben wir zuerst die get_context_data - Methode der EventCreateView-Klasse. Mit ihr ist es möglich, bei klassenbasierten Views den Kontext, der an das Template zum Rendern weitergereicht wird, zu manipulieren. Wir machen hier nichts weiter, als dem Context-Dictionary das category - Objekt, dass wir uns in der Methode get_initial geholt hatten, zu übergeben.

class EventCreateView(LoginRequiredMixin, CreateView):
    """
    create an event for a certain category
    events/event/create/3
    """

    model = Event
    form_class = EventForm

    def form_valid(self, form):
        form.instance.category = self.category
        form.instance.author = self.request.user
        return super().form_valid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["category"] = self.category
        return context

    def get_initial(self):
        self.category = get_object_or_404(Category, pk=self.kwargs["category_id"])

Um diese Information noch im Template zur Verfügung zu stellen, passen wir unser Template unter event_manager/events/event_form.html an. Im head-Bereich tragen wir folgendes ein:

{% block head %}
{% if object %}
    <p>
    <a href="{% url 'events:event_detail' object.pk %}">zurück zum Event</a><br>
    </p>

    <h1 class="display-6 fw-bold lh-1">{{object.name}}</h1>

{% else %}
    <p>
    <a href="{% url 'events:events' %}">zurück zur Übersicht</a><br>
    </p>

    <h1 class="display-5 fw-bold lh-1">Event in {{category}} eintragen</h1>

{% endif %}
{%endblock%}

In den geschweiften Klammern geben wir jetzt die category - Variable, die wir über get_context_data dem Kontext übergeben haben wieder aus. Unser Form-Template in event_manager/events/event_form.html sieht jetzt so aus:

{% extends 'base.html' %}
{% load crispy_forms_tags %}

{% block title %}
{% if object %}
    Edit {{object.name}}
{% else %}
  Event in {{category}} eintragen
{% endif %}
{%endblock%}

{% block head %}
{% if object %}
    <p>
    <a href="{% url 'events:event_detail' object.pk %}">zurück zum Event</a><br>
    </p>

    <h1 class="display-6 fw-bold lh-1">{{object.name}}</h1>
    
{% else %}
    <p>
    <a href="{% url 'events:events' %}">zurück zur Übersicht</a><br>
    </p>

    <h1 class="display-5 fw-bold lh-1">Event in {{category}} eintragen</h1>
    
{% endif %}
{%endblock%}

{% block content %}
<div class="container">
<div class="row">
<div class="col-lg-8 col-md-12 mb-5">


{% crispy form form.helper %}
        
<div>
</div>
</div>
{% endblock %}

Im Formular sehen wir jetzt, dass die Kategorie angegeben wird, in der das Event eingetragen wird:

../_images/event_form_7.png

Optional: Das Projekt in der Version v0.4 kopieren

Wie immer kann man sich den Zwischenstand klonen, falls beim Mitschreiben was schiefgegangen ist.

Version v0.4 via github clonen

git clone -b v0.4 git@github.com:realcaptainsolaris/event_project.git

und dann nicht vergessen, ein virtuelles Environment anzulegen und zu migrieren und danach noch ein paar Testdaten zu generieren:

python -m venv .envs/eventenv
pip install pip-tools
pip-sync requirements.txt requirements-dev.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py create_user -n 10
python manage.py create_events --events 20 --categories 5
python manage.py runserver

Version v0.4 als zip-Datei runterladen

https://github.com/realcaptainsolaris/event_project/archive/refs/tags/v0.4.zip

Version v0.4 als tar-Datei runterladen

https://github.com/realcaptainsolaris/event_project/archive/refs/tags/v0.4.tar.gz