einen Event via Formualar hinzufügen

Wir wollen -eingeloggten- Usern ermöglichen, Events via einem Formular einzutragen. Da wir zur Zeit noch keinen Authentifizierungsmechanismus implementiert haben, loggen wir uns über die Django-Adminoberfläche ein. Wir sind damit ordentlich authentifziert und können dann Events eintragen. Später werden wir noch einen Mechanismus einrichten, damit sich auch andere User einloggen können.

Wir wollen jetzt folgende klassenbasierte Views (class based views) für die Events anlegen: * Event eintragen * eingetragenen Event updaten

Der Plan

Wir wollen folgendes bewerkstelligen: Wenn ein User auf eine Kategorie-Detailseite, zb. unter http://127.0.0.1/events/category/sport navigiert, soll er dort einen Button vorfinden, auf dem er einen Event für diese Kategorie eintragen kann.

Ein Formular entwickeln

Folgende Dinge sind nötig, um ein Formular zu implementieren:

  • eine Formular-Klasse, die von ModelForm erbt

  • die URL, die auf das Formular führt

  • eine View, die das Formular anzeigt

  • ein Template, welches das Formular anzeigt

Formular-Klasse

Für jedes Formular, dass wir darstellen wollen, benötigen wir eine entsprechende Klasse.

Dazu legen wir eine Datei event_manager/events/forms.py an und befüllen sie mit folgendem Inhalt:

from django import forms
from django.core.exceptions import ValidationError

class EventForm(forms.ModelForm):
    class Meta:
        model = Event
        fields = "__all__"

Die Klasse EventForm erbt von forms.ModelForm. Django erstellt also aus dem Model ein entsprechendes Formular. Zusätzlich importieren wir auch noch from django.core.exceptions import ValidationError, damit wir das Formular auch Validieren können.

Formular-View

Um das Formular anzeigen zu können, müssen wir unter event_manager/events/views.py eine View entwickeln. Dazu nutzen wir wieder eine generische klassenbasierte View:

Wir importieren folgende generische Views sowie unsere eben erstelle Formularklasse:

from django.views.generic.edit import CreateView, DeleteView, UpdateView
from .forms import EventForm

Oldschool funktionsbasierte Formularview

Bevor wir uns den moderneren klassenbasierten Aufbau ansehen, gucken wir uns die etwas ältere Methode an, die immer noch genutzt werden kann und bei komplexeren Prozessen der klassenbasierten View auch vorzuziehen ist.

def create_event(request, category_id):
    """
    /event/add/<category_id>
    """
    cat = get_object_or_404(Category, pk=category_id)

    if request.method == "POST":
        form = EventForm(request.POST or None)
        if form.is_valid():
            event = form.save(commit=False)
            event.category = cat
            event.author = request.user
            event.save()
            return redirect(event.get_absolute_url())

    else:
        form = EventForm()
    return render(
        request,
        "events/event_add.html",
        {"form": form},
    )

Wir erstellen eine Funktion create_event die als Parameter das Request-Objekt und die Kategorie-ID erwartet. Wir fragen zuerst die Kategorie ab (cat = get_object_or_404(Category, pk=category_id)) und prüfen dann die HTTP-Methode.

Wenn die HTTP-Methode NICHT POST ist, also das Formular noch NICHT abgesendet wurde, erstellen wir eine Instanz des Formulars und rendern dieses in das Template. Ansonsten erstellen wir eine Instanz des Formulars und füllen es mit den Formulardaten, prüfen dann mit form.is_valid() ob es valide ist und speichern es mit Kategorie und Autor ab. Dann führen wir einen Redirect auf die absolute URL des Eventobjects aus (sprich, besuchen danach die Detailseite des neu angelegten Objektes).

event = form.save(commit=False) erstellt ein Objekt auf Basis des gefüllten Formulars, commit=False zeigt aber an, dass es nicht gespeichert werden soll. Denn bevor wir es speichern, müssen wir noch die Kategorie (die aus der URL extrahiert wird) und den Autor, der ja der eingeloggte User ist, angeben.

Etwas einfacher geht es mit der CreateView.

Die Create-View

Gucken wir uns die CreateView mal genauer an: <https://ccbv.co.uk/projects/Django/4.0/django.views.generic.edit/CreateView/>_

Folgende Attribute sind für uns erstmal von Interesse:

  • success_url: nach erfolgreichem Eintrag wird dorthin geleitet

  • template_name_suffix. Der Default Suffix ist ‘_form’, also ‘event_form’

Die View anlegen:

class EventCreateView(LoginRequiredMixin, CreateView):
    """
    create an event for 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(EventCreateView, self).form_valid(form)

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

Wir erben also von CreateView und nutzen wieder einen Mixin, diesemal den LoginRequiredMixin. Der User muss also eingeloggt sein, um diese View zu sehen. Wir definieren das Model model=Event und die Formklasse mit form_class=EventForm.

Dazu implementieren wir noch zwei Methoden: get_initial wird immer zuerst aufgerufen, wenn diese View aufgerufen wird. In ihr prüfen wir, ob die übergebene Kategorie überhaupt existiert und lösen einen 404-Fehler aus, falls dies nicht der Fall sein sollte.

Zudem implementieren wir form_valid, die aufgerufen wird, wenn das Formular abgesendet wird. In ihr weisen wir der Form-Instanz die Kategorie sowie den -eingeloggten user- als Author zu.

Mixins

Das Salz in der Suppe von klassenbasierten Views sind Mixins. So ermöglicht zum Beispiel der soeben genutze LoginRequiredMixin, dass der User, der diese View anfordert, eingeloggt sein muss. Genauer genommen bestehen unsere komfortablen generischen Views auf vielen anderen Mixin-Klassen, die genutzt werden: https://docs.djangoproject.com/en/4.0/ref/class-based-views/generic-editing/#django.views.generic.edit.CreateView

Wir können unseren Views aber auch noch andere Mixins hinzufügen: zum Beispiel den UserPassesTestMixin, der gewährleistet, dass der User einen Test besteht, zb. sein Name die Zeichenkette “Klaus” beinhaltet. Oder der PermissionRequiredMixin der prüft, ob der User die entsprechenden Rechte hat.

https://docs.djangoproject.com/en/4.0/topics/class-based-views/mixins/

Das Template für das Formular

Fehlt noch das Template für das Formular:

Wir legen unter folgende Datei an event_manager/events/templates/events/event_form.html und fügen folgenden Code hinzu:

{% extends 'base.html' %}

  {% block title %}
  Event eintragen
  {%endblock%}

  {% block head %}
  Event eintragen
  {%endblock%}

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

<form method="POST">
    {% csrf_token %}
    <table>
        {{form}}
        <tr>
            <th>
                Formular absenden:
            </th>
            <td>
                <input class="btn btn-primary" type="submit" value="Jetzt eintragen" />
            </td>
        </tr>
    </table>
</form>

{% endblock %}

In {{form}} wird das Formular gerendert, der {% csrf_token %} dient der Sicherheit. Mehr zum CSRF-Token hier: https://docs.djangoproject.com/en/4.0/ref/csrf/

Die URL

Wir fügen jetzt die URL in die event_manager/events/urls.py ein:

urlpatterns = [
    path("hello_pingus", views.hello_pingus, name="hello_pingus"),
    path("categories", views.list_categories, name="categories"),
    path("category/<slug:slug>", views.category_detail, name="category_detail"),
    path("", views.EventListView.as_view(), name="events"),
    path(
    "event/<slug:slug>",
    views.EventDetailView.as_view(),
    name="event_detail"
    ),

    # diese Zeile hinzufügen:
    path(
    "event/create/<int:category_id>",
    views.EventCreateView.as_view(),
    name="event_create",
    ),

]

Unter folgender URL können wir jetzt einen Event eintragen: http://127.0.0.1:8000/events/event/create/42.

Verlinkung von der Kategorie-Detailseite aus

Wir wollen die Möglichkeit schaffen, von der Kategorie-Detailseite aus einen Event einzustellen. Dazu öffnen wir das Template für die Kategorie-Detailseite unter event_manager/events/templates/events/category_detail.html und fügen folgenden Code ein:

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

{% block title %}
Kategorie {{category.name}}
{%endblock%}

{% block head %}
Kategorie {{category.name}}
{%endblock%}

{% block content %}
<p>{{category.description}}</p>
<p>created at: {{category.created_at}}

<p>
<a href="{% url 'events:event_create' category.pk %}">neues Event eintragen</a>
</p>
{%endblock%}

Wenn wir jetzt nach http://127.0.0.1:8000/events/category/sport navigieren, kommen wir über den Link unten auf das Formular.

Crispy Forms

Um den Look des Formulars zu verbessern, installieren wir mit aktiviertem Environment via pip das Modul Crispy forms bzw. das Crispy-Bootstrap5-Paket. Dieses benötigen wir, um mit Bootstrap5 zu arbeiten.

$ (eventenv) pip install django-crispy-forms
$ (eventenv) pip install crispy-bootstrap5

und fügen es unter settings.py den installierten Apps hinzu:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "crispy_bootstrap5",
    "crispy_forms",
]

# ebenfall in die settings.py
CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"

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

jetzt ändern wir das Template event_manager/events/templates/events/event_form.html wie folgt ab:

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

{% block title %}
Event eintragen
{%endblock%}

{% block head %}
Event eintragen
{%endblock%}

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

<form method="POST">
    {% csrf_token %}
    <table>
        {{form|crispy}}
        <tr>
            <th>
                Formular absenden:
            </th>
            <td>
                <input class="btn btn-primary" type="submit" value="Jetzt eintragen" />
            </td>
        </tr>
    </table>
</form>

{% endblock %}

Das Formular sollte jetzt deutlich schöner aussehen.

Das Formular weiter verbessern

Das Formular hat diverse Problembereich: zuerst befinden sich einige Felder in ihm, die wir nicht darstellen wollen, zum Beispiel die Auswahl der Kategorie, denn diese Auswahl haben wir schon getroffen. Wir wollen auch nicht, dass der Autor auswählbar ist, denn dies wäre eine Sicherheitslücke. Zudem wollen wir verhindern, dass der Slug manuell eingegeben werden kann.

Felder exkludieren

Um zu verhindern, dass Model-Felder in Formularen dargestellt werden, nutzen wir das Attribut exclude. In unserem Fall wollen wir aus dem Event-Formular die Felder slug, category und author entfernen. Der Slug wird automatisch beim Speichern des Formulars erstellt, und die Kategorie bzw. den Author fügen wir selbständig hinzu. Der User soll ja nicht auswählen können, wer den Event eingetragen hat.

class EventForm(forms.ModelForm):
    class Meta:
        model = Event
        fields = "__all__"
        exclude = ("slug", "category", "author")

Ein Problem ist jetzt noch die Eingabe des Datums. Um diese zu optimieren, werden wir ein Widget verwenden, um ein Kalenderwidget statt eines einfachen Textfeldes zu erzeugen.

Widgets

ein Widget ist Djangos Darstellung eines HTML-Eingabeelements. Das Widget handhabt das Rendern des HTML und das Extrahieren von Daten aus einem GET/POST-Wörterbuch, das dem Widget entspricht.

Widgets können wir u.a. nutzen, in dem wir der Form-Klasse das widgets-dictionary für die Felder definieren, die wir überschreiben wollen.

Wir wollen für das Feld date statt eines Textfeldes eine DateInput-Komponente erzeugen. Diese Datums-Komponente ist browserspezifisch, Firefox stellt den Kalender also anders da als Google Chrome.

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

Es können natürlich auch eigene, speziellere Widgets entwickelt werden, was über den Scope dieses Buches hinausgeht.

Mehr zu Widgets finden sich hier: <https://docs.djangoproject.com/en/4.0/ref/forms/widgets/>_

An dieser Stelle führen wir noch eine Validierung durch: wir prüfen, ob der Sub-Title des Events mit Sonderzeichen beginnt und verhindern dies, in dem wir das einen ValidationenError erheben, falls dies passiert.

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

Auf das Dict self.cleaned_data kann man gezielt per Key zugreifen. Dort befinden sich alle Einträge, aus denen der User-Input in Python-Objekte umgewandelt wurde und die save-Methode überstanden haben. Wir können einen ValidationError erheben und damit das Formular zum User zurücksenden lassen. Der Rückgabewert der Methode clean_sub_title ist der neue Inhalt des Feldes sub_title-.

Formulareingabe-Validierung

In Django kann die Eingabevalidierung an mehreren Ebenen stattfinden, je nachdem, was man benötigt. Zum Beispiel wollen wir verhindern, dass Events mit einem Namen kleiner als zwei Zeichen eingegeben werden oder ungültige Email-Adressen.

Auf Model-Ebene Wir können Model-Feldern (auch eigene) Validatoren zuweisen. Dies passiert in der Datei models.py. Diese Model-Validatoren werden auch in der Admin-Oberfläche berücksichtigt.

Auf Formular-Ebene Wir können direkt in der Form-Klasse Felder validieren. Dazu nutzen wir entweder die Methode clean_<FELDNAME> um gezielt Funktionen zur Feldüberprüfungen zu schreiben oder die Funktion clean, um Felder zu überprüfen, die voneinander abhängen. Die Formular-Validatoren werden NICHT in der Admin-Oberfläche berücksichtigt.

Mehr dazu unter: <https://docs.djangoproject.com/en/4.0/ref/forms/validation/#cleaning-a-specific-field-attribute>_

Auf View-Ebene man könnte auch in den Views die Eingabe validieren, davon wird jedoch abgeraten.

Ablauf des Validierungsprozesses siehe https://stackoverflow.com/questions/38394078/django-form-validation-overview-quick

Usere EventForm Klasse unter event_manager/events/forms.py sieht jetzt so aus:

from django import forms
from django.core.exceptions import ValidationError


class EventForm(forms.ModelForm):
    class Meta:
        model = Event
        fields = "__all__"
        exclude = ("slug", "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):
        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

Ein Objekt updaten

Wir wollen dem User jetzt die Möglichkeit geben, Events auch upzudaten.

  • nur die Eigentümer eines Events dürfen updaten / löschen

Template erstellen für Update Event

Für das Update-Formular nutzen wir ebenfalls das schon bei EventCreate genutzt event_form.html. Dazu müssen wir im Template allerdings unterscheiden, ob wir gerade die Create-View aufrufen oder die Update-View. Dazu können wir prüfen, ob ein object-Objekt im Kontext übergeben wurde.

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

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

{% block head %}
{% if object %}
    Event {{object.name}} updaten
{% else %}
    Event eintragen
{% endif %}
{%endblock%}

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

<form method="POST">
    {% csrf_token %}
    <table>
        {{form|crispy}}
        <tr>
            <th>
                Formular absenden:
            </th>
            <td>
                <input class="btn btn-primary" type="submit" value="Jetzt speichern" />
            </td>
        </tr>
    </table>
</form>

{% endblock %}

View erstellen für das Updaten eines Events

Unter event_manager/events/views.py die View anlegen:

from django.core.exceptions import PermissionDenied

class EventUpdateView(LoginRequiredMixin, UpdateView):
    """
    update an event
    events/event/update/3
    """

    model = Event
    form_class = EventForm

    def form_valid(self, form):
        if form.instance.author != self.request.user:
            raise PermissionDenied(_("Zugriff ist leider nicht erlaubt"))
        return super(EventUpdateView, self).form_valid(form)

Wir erben also von UpdateView (welches wir vorhin schon importiert hatten) und importieren auch noch die PermissionDenied-Exception aus den django.core.exceptions. Wieder nutzen wir den LoginRequiredMixin. Der User muss also eingeloggt sein, um das Event-Model updaten zu dürfen.

Nicht upgedaten werden können die Felder category, author und slug, wie wir das in der Formklasse schon definiert hatten.

In der form_valid-Methode prüfen wir, ob der Autor des Events auch dem User entspricht. Falls das nicht der Fall sein sollte, erheben wir eine PermissionDenied-Exception, die in einem 403 Forbidden-Error für den User resultiert.

Eigene Fehlerseiten

Für folgende HTTP-Statuscodes lassen sich unter event_manager/templates Fehlerseiten anlegen:

  • 404 dazu eine eigene 404.html anlegen

  • 500 dazu eine eigene 500.html anlegen

  • 403 dazu eine eigene 403.html anlegen

Die URL

Wir fügen jetzt die URL in die event_manager/events/urls.py ein:

urlpatterns = [
    path("hello_world", views.hello_world, name="hello_world"),
    path("categories", views.list_categories, name="categories"),
    path("category/<slug:slug>", views.category_detail, name="category_detail"),
    path("", views.EventListView.as_view(), name="events"),
    path(
    "event/<slug:slug>",
    views.EventDetailView.as_view(),
    name="event_detail"
    ),
    path(
    "event/create/<int:category_id>",
    views.EventCreateView.as_view(),
    name="event_create"
    ),

    # diese Zeile hinzufügen:
    path(
    "event/update/<int:pk>",
    views.EventUpdateView.as_view(),
    name="event_update"
    ),
]

Verlinkung von der Events-Detailseite

Um das Update-Formular aufrufen zu können, benötigen wir eine Verlinkung. Dazu öffnen wir das Template für die Event Detailseite event_manager/events/templates/events/event_detail.html und fügen folgenden Code hinzu:

{% if user.is_authenticated and user == object.author %}
<p><a href="{% url 'events:update_event' object.id %}">Event editieren</a></p>
{% endif %}

Wir prüfen also, ob der User eingeloggt ist und wenn ja, ob er auch Autor des Events ist. Nur dann darf er das Formular auch editieren und nur dann wir der Link dazu angezeigt.

Das Template unter event_manager/events/templates/events/event_detail.html sieht jetzt so aus:

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

  {% block title %}
  Event: {{object.name}}
  {%endblock%}

  {% block head %}
  {{object.name}}
  {%endblock%}

{% block content %}
<h1>{{object.name}}</h1>
<p>{{object.sub_title}}</p>
<p>Category: {{object.category}}</p>

<h3>Beschreibung</h3>
<p>{{object.description}}</p>
<p>findet statt am: {{object.date}}</p>
<p>created at: {{object.created_at}}</p>

{% if object.related_events %}
    <h4>Ähnliche Events</h4>
    <ul>
    {% for related in object.related_events %}
    <li>{{related.name}}</li>
    {% endfor %}
    </ul>
{% endif %}

<p><a href="{% url 'events:update_event' object.id %}">Event editieren</a></p>

{%endblock%}

Ein Objekt löschen

URL

path(
    "event/delete/<int:pk>",
    views.EventDeleteView.as_view(),
    name="event_delete",
),

View

class EventDeleteView(DeleteView):
    # default Templates: company_confirm_delete
    model = Event

    def form_valid(self, form):
        if form.instance.author != self.request.user:
            raise PermissionDenied(_("Zugriff ist leider nicht erlaubt"))
        return super(EventUpdateView, self).form_valid(form)

    def get_success_url(self) -> str:
        return reverse("events:events")

Event ist nur löschbar, wenn der eingeloggte User auch der Autor ist. Nach erfolgreichem Löschen wird auf http://127.0.0.1/events/events weitergeleitet.

Template

Unter event_manager/events/templates/events/event_confirm_delete.html legen wir das Template an.

{% extends 'base.html' %}

{% block head %}
<h1>Event löschen: {{object.name}}</h1>
{% endblock %}

{% block content %}

    <form method="post">
        {% csrf_token %}
        <p>Willst du das Event sicher löschen? "{{ object }}"?</p>
        <input type="submit" class="btn btn-primary" value="Ja, weg damit!">
    </form>

{% endblock %}

Event-Detailseite

und modifizieren die Event-Detailsieite

{% if user.is_authenticated and user == object.author %}
<p
<a href="{% url 'events:update_event' object.id %}">Event editieren</a><br>
<a href="{% url 'events:delete_event' object.id %}">Event löschen</a><br>
</p>
{% endif %}

Das Template unter event_manager/events/templates/events/event_detail.html sieht jetzt so aus:

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

  {% block title %}
  Event: {{object.name}}
  {%endblock%}

  {% block head %}
  {{object.name}}
  {%endblock%}

{% block content %}
<h1>{{object.name}}</h1>
<p>{{object.sub_title}}</p>
<p>Category: {{object.category}}</p>

<h3>Beschreibung</h3>
<p>{{object.description}}</p>
<p>findet statt am: {{object.date}}</p>
<p>created at: {{object.created_at}}</p>

{% if object.related_events %}
    <h4>Ähnliche Events</h4>
    <ul>
    {% for related in object.related_events %}
    <li>{{related.name}}</li>
    {% endfor %}
    </ul>
{% endif %}

<p><a href="{% url 'events:update_event' object.id %}">Event editieren</a></p>

{%endblock%}