Slugs einbauen

Ein Slug ist der Teil der URL, der eine Seite eindeutig identifiziert, und er hat ein Format, das sowohl für Benutzer als auch für Suchmaschinen leicht lesbar ist.

Der Kategorie ein Slugfeld zuweisen

Wir wollen, dass die Kategorie später auch per Slug aufgerufen werden kann, also zb. http://127.0.0.1/events/category/sport-und-freizeit. Dazu definieren wir ein Slug-Feld, dessen Wert in einem späteren Schritt automatisch generiert wird.

Um den Slug-Wert eines Feldes automatisch aus dem Namen zu generieren, überschreiben wir die save()-Methode der Klasse Category:

def save(self, *args, **kwargs):
    if not self.slug:
        self.slug = slugify(self.name)
    super().save(*args, **kwargs)

save() wird immer aufgerufen, wenn eine Instanz dieser Klasse gespeichert wird. Wenn das Objekt nun gespeichert wird und bisher noch KEIN Slug vergeben wurde, setzen wir den Slug für dieses Objekt. Der Slug basiert auf dem name-Attribut und wird durch die Funktion slugify aus den django.utils.text importiert. slugify wandelt einen Text in eine URL-taugliche Variante um, also keine Leerzeichen, keine Umlaute und keine sonstigen Zeichen, die in einer URL nicht erlaubt sind.

Best Practice: Slugs

Slugs dienen als eindeutige Bezeichner, ähnlich wie IDs. Auf sie wird referenziert, über sie sind Resourcen addressierbar. Ein nachträgliches Ändern von Slugs ist problematisch, da zum Beispiel von Google schon eingelesene Links dann nicht mehr gefunden werden könnten. Aus diesem Grund müssen Slugs auch eindeutig sein (unique).

Dem Event ein Slugfeld zuweisen

name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)


def save(self, *args, **kwargs):
    if not self.slug:
        self.slug = slugify(self.name)
    super().save(*args, **kwargs)

Eindeutiger Slug

Die save-Funktion oben hat einen Nachteil: es kann passieren, dass bei unterschiedlichem Namen trotzdem der selbe Slug erstellt wird. So erstellt slugify aus oe-aa den Slug oe-aa. Gleiches passiert aber bei öe-aa. Wir hätten dann unterschiedliche Werte für das Name-Attribut, aber gleichlautende Slugs. Das Programm würde einen Fehler werfen, da die unique-Constraint verletzt würde.

Bei dem Category-Modell oben lassen wir das jetzt mal so, da die Kategorien in unserem Projekt nicht usergeneriert sind, sondern von der Moderation so vorgegeben werden.

Anders wird es bei den Events sein. Dort kann es bei vielen Einträgen durchaus vorkommen, dass Events den gleichen Namen unter Umständen sogar in der gleichen Categorie haben. Und da die Event-Slugs genauso wie Event-Namen eindeutig sein müssen, müssen wir das anders machen.

Deshalb nutzen wir eine selbstgeschriebene Funktion slugify_instance_name, die auf Basis des Namens eines Objekts einen Slug rekursiv erstellt, bis er eindeutig ist. Dazu wird einfach eine Zufallszahl angehängt, falls der Eintrag schon existieren sollte.

Wir erstellen eine appübergreifende Datei unter event_manager/event_manager/utils.py und kopieren dort folgenden Code hinein:

import random
from django.utils.text import slugify

def slugify_instance_name(instance: object, new_slug: str = "") -> str:
    """Erstelle einen eindeutigen Slug für ein Django Model.

    Es wird versucht, einen eindeutigen Slug auf Basis des name-Attributes
    eines Models zu erstellen. Falls der Slug schon existiert, soll die
    Funktion rekursiv aufgerufen werden, bis ein Slug erstellt ist, der noch
    nicht existiert. Dazu wird die random Funktion genutzt, um eine
    Zufallszahl an den Slug anzuhängen.

    Der alternative Slug ist vom Format {slug-name}-RANDOM_NUMBER

    Args:
        instance: Django Model
        new_slug: str

    Returns:
       slug (str)
    """
    min_random = 10_000
    max_random = 800_000

    if new_slug:
        slug = new_slug
    else:
        slug = slugify(instance.name)

    # Prüfen, ob slug schon existiert, wenn ja, rufe funktion erneut auf
    # Wir müssen die eigene Instanz natürlich exkludieren bei der
    # Abfrage
    cls = instance.__class__
    qs = cls.objects.filter(slug=slug).exclude(id=instance.id)
    if qs.exists():
        random_number = random.randint(min_random, max_random)
        slug = f"{slug}-{random_number}"
        return slugify_instance_name(instance, new_slug=slug)
    return slug

Und importieren diese Funktion in unsere event_manager/events/models.py:

from event_manager.utils import slugify_instance_name

Um diese Funktion jetzt nutzen zu können, benötigen wir noch ein Signal. Der Grund ist folgender und vielleicht nicht auf den ersten Blick ersichtlich: Wenn wir (gleich im nächsten Kapitel) einen neuen Eintrag über die Django-Administrationsoberfläche erstellen werden, nutzen wir eine Technik namens prepopulated_fields. Diese Technik generiert fronteindseitig einen Slug für das Objekt.

Unsere eigene, rekursive Funktion kann allerdings von der Administration nicht genutzt werden und wir würden einen Fehler provozieren, wenn wir den Slug einfach leer lassen.

Hier kommen Signale ins Spiel….

Signale: eine von Djangos Geheimwaffe

Django Signal erlauben es bestimmten Sendern (sender), eine Gruppe von Empfängern (Receivern) zu benachrichtigen, dass eine Aktion stattgefunden hat. Es handelt sich hierbei also um System-Events, die zu gewissen Aktionen von Django automatisch ausgelöst werden und die wir abfragen können. So können wir zum Beispiel definieren, dass für eine Instanz ein Slug erstellt werden soll, bevor das Objekt gespeichert wird.

Django bietet sieben eingebaute Signale:

pre_init

wenn ein Django-Model instantiiert wird

post_init

nachdem ein Django-Model instantiiert wurde

pre_save

bevor ein Model gespeichert wird

post_save

nachdem ein Model gespeichert wurde

pre_delete

bevor ein Model gelöscht wurde

post_delete

nachdem ein Model gelöscht wurde

m2m_changed

wenn eine Many-to-Many-Beziehung verändert

wurde

Django Signals und inbesondere das pre_save sind übrigens auch der ideale Ort, um für einen neuen User ein User-Profil zu erstellen. Das ist einfach ein Model mit Referenz auf einen User, in dem weitere Daten wie Hobbies, User-Foto oder Freundesliste abgebildet werden können.

https://docs.djangoproject.com/en/4.1/topics/signals/

Wir werden in diesem Buch nicht weiter auf Signale eingehen, da es sich um ein zu weit führendes Thema handelt. Trotzdem werden wir ein pre_save-Signal für das Event-Modell nutzen, um einen Slug zu erstellen.

Wir fügen in event_manager/events/models.py ganz unten folgenden Code ein:

@receiver(pre_save, sender=Event)
def create_slug(sender, instance, *args, **kwargs):
    print("pre save wurde ausgeführt")
    if not instance.slug:
        instance.slug = slugify_instance_name(instance, new_slug=None)

Hier haben wir jetzt ein pre_save-Signal erstellt. Immer, wenn ein Event-Model gespeichert wird, wird diese Funktion aufgerufen, sie ist somit der receiver. Der sender ist das Event-Model selbst und das Signal ist pre_save. Wir haben noch ein print-Statement in die funktion gepackt, damit man auf der Konsole sehen kann, wann das Signal ausgeführt wird. Später werden wir das hier natürlich rauslöschen.

Damit haben wir nun unser erstes Signal erstellt! Jetzt löschen wir die save-Methode wieder aus dem Event-Modell, da wir die nicht mehr brauchen und sind somit fertig.

der richtige Ort für Signale

Ein kleiner Hinweis noch: Der richtige Ort für die receiver-Funktionen bzw. überhaupt für das Arbeiten mit Signalen ist nicht die models.py. Wir haben das wider besseren Wissens und der Einfachheit halber hier angelegt. Wer die Muße dazu hat, möge eine event_manager/events/signals.py anlegen. Dort ist der richtige Ort, die Signale für die App events zu platzieren:

from django.db.models.signals import pre_save
from django.dispatch import receiver
from event_manager.utils import slugify_instance_name
from .models import Event

@receiver(pre_save, sender=Event)
def create_slug(sender, instance, *args, **kwargs):
    print("pre save wurde ausgeführt")
    if not instance.slug:
        instance.slug = slugify_instance_name(instance, new_slug=None)

um die Signale jetzt auch noch aktiv zu machen, müssen noch zwei weitere Dateien verändert werden. In der event_manager/events/app.py legen wir noch eine ready-Methode an, die aufgerufen wird, wenn die Klasse geladen ist:

from django.apps import AppConfig

class EventsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "events"

    def ready(self):
        import event_manager.events.signals

und in die event_manager/events/__init__-Datei wird noch folgender Eintrag hinterlegt:

default_app_config = 'event_manager.events.apps.EventsConfig'
  • mit prepopulated_fields machen wir frontendseitig einen Slug aus name

Da sollte funktionieren. Wir wollen jetzt noch eine weitere Änderung vornehmen, bevor wir uns an die Verlinkung machen. Eine URL wie http://127.0.0.1:8000/events/category/3 ist zwar möglich, aber nicht schön.

Da wir beim Entwickeln der Models für Events und Kategorien ja schon einen Slug eingebaut haben, wollen wir den jetzt auch gleich nutzen. Wir können dann die URLs im Format http://127.0.0.1:8000/events/category/sport aufrufen.

Dazu ändern wir die urlpatterns in den event_manager/events/urls.py ab:

urlpatterns = [
    path("hello_world", views.hello_world, name="hello_world"),
    path("categories", views.categories, name="categories"),

    # diese Zeile ändern:
    path("category/<slug:slug>", views.category_detail, name="category_detail"),
]
class EventListView(ListView):
    """http://127.0.0.1:8000/events/
       http://127.0.0.1:8000/events/?category=sport
    """

    model = Event
    paginate_by = 3

    def get_queryset(self):
        queryset = Event.objects.prefetch_related("category").all()
        if slug := self.request.GET.get("category"):
          queryset = queryset.filter(category__slug=slug)

        return queryset

Zweitens haben wir einen kleinen Filter eingebaut: Wird die URL http://127.0.0.1:8000/events/?category=sport eingegeben, erhalten wir nur noch Events aus der Kategory Sport. Dazu filtern wir das queryset nach dem Slug der Kategorie. Wir haben also einen Kategoriefilter erstellt.

Die View für die Detailseite anpassen

Dazu öffnen wir die event_manager/events/views.py und importieren die generische DetailView. Dann legen wir die EventDetailView-Klasse an. Der Event soll ebenfalls per slug aufgerufen werden können.

from django.views.generic.detail import DetailView

class EventDetailView(DetailView):
    """
    events/event/linux-user-gathering
    """

    model = Event

Unser Event ist also unter http://127.0.0.1:8000/events/event/linux-user-gathering aufrufbar.

Die URL anlegen

Dazu tragen wir eine neue URL in event_manager/events/urls.py ein:

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

    # diese Zeile hinzufügen:
    path("event/<slug:slug>", views.EventDetailView.as_view(), name="event_detail"),
]

Verlinkung auf die Detailseite

Was jetzt noch fehlt, ist eine Verlinkung auf die Detailseite von der Event-Übersichtsseite aus.

Dazu öffnen wir das Template unter events/templates/events/event_list.html und fügen einen Hyperlink mit dem url-Tag ein. Da event/<slug:slug> aus den URLs einen Slug erwarten, übergeben wir den event.pkg.

..
<a href="{% url 'events:event_detail' event.slug %}">
<li class="list-group-item list-group-item-info">
..

</li>
</a>

Unsere Template events/templates/events/event_list.html unter sieht jetzt so aus:

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

{% block title %}
Übersicht der Events
{%endblock%}

{% block head %}
Übersicht der Events
{%endblock%}

{% block content %}

<ul class="list-group event_box">
{% for event in object_list %}
  <a href="{% url 'events:event_detail' event.slug%}">
    <li class="list-group-item rounded">
      <b>{{event.name}}</b>
      <span class="badge rounded-pill bg-primary">
        {{event.category.name}}
      </span>
        <p>by {{event.author}}</p>
    </li>
  </a>
{% endfor%}
</ul>

{% include "../snippets/paginator.html" %}

{%endblock%}

Absolute URL zur Detailseite

Eine Sache müssen wir jetzt noch machen. Wir müssen für das Event-Model eine Methode implementieren, die die absolute URL zu einer Instanz der Klasse zurückgibt. Wir werden diese später noch brauchen, wenn wir ein neues Event-Objekt mit einem Formular einfügen und daraufhin auf dieses Objekt weitergeleitet werden wollen.

Wir öffnen also die Datei event_manager/events/models.py und fügen der Event-Klasse folgendes hinzu:

from django.urls import reverse
..
..

class Event(DateMixin):
    ..
    ..
    def get_absolute_url(self):
        return reverse("events:event_detail", args=[str(self.pk)])

Usere EventForm Klasse unter event_manager/events/forms.py sieht jetzt so aus: bild stimmt noch nicht!

from django import forms
from django.core.exceptions import ValidationError
from .models import Event, Category


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


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

Den Kategorie-Slug in der Event-Übersicht darstellen

Bisher zeigen wir nur die Kategorie in der Eventübesicht an. Wir wollen die Ansicht aber erweitern, und den Slug der Category auch noch darstellen.

Leider funktioniert der Zugriff via category__slug in der list_display-Eigenschaft nicht. Es kommt zu einem Fehler

@admin.register(Event)
class EventAdmin(admin.ModelAdmin):

    # ... mehr Code
    list_display = ("date", "slug", "author", "name", "category", "category__slug")

Um das Problem zu umgehen, schreiben wir uns eine eigene Methode, dekorieren sie mit dem @admin.display-Dekorator und nutzen den Methoden-Namen in list_display, um die Spalte anzuzeigen.

@admin.register(Event)
class EventAdmin(admin.ModelAdmin):

    # mehr Code
    list_display = ("date",
                    "slug",
                    "author",
                    "name",
                    "category",
                    "category_slug")

    @admin.display(description="Kategorie Slug")
    def category_slug(self, obj):
        return obj.category.slug

Wir können problemlos eigene Methoden in der EventAdmin-Klasse definieren, und diese als Felder in der Übersicht unserer Events darstellen. Das übergebene obj ist im Fall von category_slug das Event-Objekt selbst.

Die fertige EventAdmin-Klasse sieht jetzt so aus:

@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
    prepopulated_fields = {"slug": ("name",)}
    list_display = (
        "date",
        "slug",
        "author",
        "name",
        "category",
        "category_slug",
    )
    list_filter = ("category",)
    search_fields = ["name"]
    actions = ["make_active", "make_inactive"]
    # date_hierachy = "date"
    readonly_fields = ("author",)
    list_display_links = ("name", "slug")
    radio_fields = {
        "category": admin.HORIZONTAL,
        "min_group": admin.VERTICAL,
    }

    @admin.action(description="Setze Events active")
    def make_active(self, request, queryset):
        """Set all Entries to active."""
        queryset.update(active=True)

    @admin.action(description="Setze Events inactive")
    def make_inactive(self, request, queryset):
        """Set all Entries to inactive."""
        queryset.update(active=False)

    @admin.display(description="Kategorie Slug")
    def category_slug(self, obj):
        return obj.category.slug