Eine Suche implementieren

Ein weiteres Feature für unseren Event-Manager soll ein Suchfeld sein, dass uns ermöglicht, nach Events zu suchen. Dafür benötigen wir ein Formular mit einem Suchfeld und eine Ausgabe-View.

Die Suchbox

In unserem base.html-Layout haben wir oben im Navigationsbereich schon ein Suchfeld implementiert, welchem wir allerdings bisher noch kein Leben eingehaucht haben.

Gucken wir uns das Layout unter event_manager/event_manager/templates/base.html nochmal an und suchen die Stelle mit dem Suchformular heraus. Das ist etwa in der Zeile 39 der base.html.

<form action="" method="get" class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3">
   <input name="q" required="required" type="search" class="form-control form-control-dark" placeholder="Search..." aria-label="Search">
</form>

Wir sehen ein Formular mit folgenden Eigenschaften:

  • die HTTP-Methode ist GET

  • das Formular hat keinen Submit-Button

  • das Input-Feld ist vom Typ search und kann folglich via <ENTER> abgesendet werden

  • Das Input-Feld hat das name-Attribut mit dem Wert q.

  • das action-Attribut ist momentan noch leer, es gibt also noch keinen Endpunkt, an den wir das Formular senden könnten.

Das ist also unser Suchformular. Um dem Formular Leben einzuhauchen, müssen wir eine View schreiben, eine URL definieren und ein Template bereitstellen.

Wenn das Formular per <ENTER> abgesendet wird, wird eine GET-URL in der Form http://www.example.com?q=suchwort gesendet werden. Der String nach dem Fragezeichen wird als Querystring bezeichnet, was nichts anderes als eine Key-Value-Liste ist. Diese Liste können wir in der View auswerten und darauf reagieren. Wir wollen den Key q auswerten, das steht für Question.

Mehr zum Thema Querystring auf Wikipedia:

https://de.wikipedia.org/wiki/Query-String

Die View für das Suchergebnis

Beginnen wir mit der View für das Such-Ergebnis. Wir haben uns für eine generische List-View entschieden. Die Such-Ergebnisse allerdings wollen wir in ein anderes, eigenes Template rendern. Das müssen wir später unter events/event_search_results.html anlegen.

Außerdem soll das Such-Ergebnis paginierbar sein. Bauen wir die View also unter event_manager/events/views.py ein:

from django.views.generic.list import ListView
from django.core.exceptions import BadRequest

class EventSearchView(ListView):
    model = Event
    template_name = 'events/event_search_results.html'
    paginate_by = 10
    queryset = Event.objects.prefetch_related("author", "category").all()

    def get_queryset(self):

        q = self.request.GET.get("q")

        if not q:
            return Event.objects.none()

        result = (
            queryset.filter(name__icontains=q)
            | queryset.filter(sub_title__icontains=q)
        )
        return result

Um zu Erreichen, dass der GET-Parameter des Querystrings q ausgewertet werden kann, überschreiben wir die get_queryset-Methode. Zuerst holen wir uns den Wert von q und geben eine leeres Queryset zurück, wenn q keinen validen Wert hat. Dies erreichen wir mit der none- - Methode des Model-Managers. Eine Anfrage a la http://example.com/search?= würde also in einer leeren Menge resultieren.

Userfreundliche APIs und Eingabefelder

Wir hätten statt der leeren Menge, d.h. des leeren Querysets natürlich auch einen Fehler werfen können und dem User einen BadRequest um die Ohren hauen können. Ein BadRequest ist eine Exception mit dem HTTP-Statuscode ref Falls wir unter event_manager/templates eine eigene ref.html angelegt haben, würde diese nun angezeigt. Allerdings nur, wenn wir im Produktivbetrieb sind und DEBUG auf False gesetzt haben, im Idealfall über unsere .env-Datei.

Unsere Variante ist natürlich userfreundlicher und man sollte sich gut überlegen, wann Eingaben in Fehler resultieren.

Hat q einen Wert, filtern wir diese Suchzeichenkette via icontains, dh. unter Nichtberücksichtigung der Groß-und-Kleinschreibung, die Namen und Sub-Title-Felder der Events. Mit dem |-Symbol erreichen wir eine ODER-Operation. Die Suchzeichenkette muss also entweder im Namen des Events oder in seinem Sub-Titel vorkommen.

Das Ergebnis der Suche wird zurückgegeben und in der event_manager/events/templates/events/event_search_results.html ausgegeben werden.

professionelle Volltextsuchen

Unsere kleine, selbstgebastelte Suche wird später ganz gut funktionieren und wird sich auch im Einsatz einer kleinen Web-Applikation ganz gut machen. Für professionelle Ansprüche mit vielen Hundertausenden Anfragen und Suchen, die auch die auch komplexere Fragemuster, boolsche Operatoren oder Negationen berücksichtigen, wird sie allerdings nicht ausreichen.

Hier sei auf Elasticsearch verwiesen. Elasticsearch ist eine Open-Source-, REST-konforme, verteilte Such- und Analyse-Engine, die auf Apache Lucene basiert und in Java geschrieben wurde.

Seit ihrer Veröffentlichung 2010 hat sich Elasticsearch schnell zur beliebtesten Suchmaschine entwickelt. Sie wird häufig für Log-Analysen, Volltextsuche, Geschäftsanalysen und Betriebsinformationen verwendet.

Mit Django Elasticsearch DSL und Django Elasticsearch DSL DRF stehen neben Haystack gute Python-Packages zur Verfügung, um Django und Elasticsearch komfortabel zu betreiben.

URLs für die Event App

Wir erweitern die URLS der Event-App unter event_manager/events/urls.py und tragen folgenden Path für in die urlpatterns ein:

app_name = "events"

urlpatterns = [
   path("search", views.EventSearchView.as_view(), name="event_search"),
   ...
]

HTML für die Ausgabe der Suchergebnisse

für die Ausgabe benötigen wir ein Template. Das legen wir unter event_manager/events/templates/events/event_search_results.html an.

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

{% block head%}
{% trans 'Search Results' %}
{% endblock %}

{% block content %}

<ul class="list-group event_box">
{% for event in event_list %}
<a href="{% url 'events:event_detail' event.slug %}">
<li class="list-group-item list-group-item rounded">

 <b>{{event.name}}</b>
    <span class="badge badge-primary badge-pill">{{event.category.name}}</span>
    <p>by {{event.author}}</p>
</li>
</a>
{% endfor%}
</ul>

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

 {%endblock%}

Wenn wir jetzt einen Such-String in die Suchbox eingeben und mit <ENTER> bestätigen, sollten uns die Suchergebnisse jetzt sauber ausgegeben werden. Allerdings gibt es noch einen kleinen Schönheitsfehler: die Pagination klappt nicht richtig. Bei einer Suche nach http://127.0.0.1:8000/events/search?q=as gehen unsere GET-Parameter verloren, wenn wir mit dem NEXT-Button eine Seite weiterklicken wollen. Das resultiert knallhart in einem BadRequest ref-Fehler, weil der q-Parameter nicht vorhanden ist.

Um dieses Problem zu umgehen, müssen wir das paginator.html umbauen und uns einen eigenen Template-Tag schreiben.

Schauen wir uns das Paginator-Snippet unter event_manager/templates/snippets/paginator.html nochmal genauer an:

{% if is_paginated %}
  <nav aria-label="Page navigation conatiner"></nav>
  <ul class="pagination justify-content-center">

    {% if page_obj.has_previous %}
      <li><a href="?page={{ page_obj.previous_page_number }}" class="page-link">&laquo; PREV </a></li>
    {% endif %}

    {% if page_obj.has_next %}
      <li><a href="?page={{ page_obj.next_page_number }}" class="page-link"> NEXT &raquo;</a></li>
    {% endif %}

  </ul>
  </nav>
{% endif %}

Im Grunde prüfen wir, ob die Ausgabe überhaupt paginiert ist ({% if is_paginated %}). Falls das zutrifft, prüfen wir mit if page_obj.has_next, ob es eine weitere Seite mit Suchergebnissen gibt. In diesem Fall stellen wir einen NEXT-Button da, um auf die nächste Seite zu gelangen. Dieses Steuern der Anzeigeseiten geschieht über den Query-String-Parameter page.

Die URL http://127.0.0.1:8000/events/search?page=2 würde uns also zur zweiten Seite mit Suchergebnissen führen.

Wie funktioniert Pagination?

aus der Doku: Django provides high-level and low-level ways to help you manage paginated data – that is, data that’s split across several pages, with “Previous/Next” links.

Nehmen wir an, wir haben 100 Suchergebnisse erhalten, die wir dem User auf einer Ergebnis-Seite darstellen wollen. Oftmals ist es so, dass wir dem User nicht die gesamte Menge auf einmal präsentieren wollen, sondern eher häppchenweise. Google macht das zum Beispiel bei den Suchergebnissen so.

Dieses häppchenweise Darstellen von Datenmengen nennt sich Pagination. Wir erstellen quasi Unterseiten mit einer festen Anzahl an Ergebnissen auf mehrere Seiten verteilt.

Wenn wir die Anzahl auf 10 Datensätze pro Seite festlegen und wir 100 Datensätze haben, benötigen wir 10 Darstellungsseiten. Das sind die pages. page=1 wären die ersten 10 Treffer, page=2 die zweiten 10 Treffer und so weiter.

Mit der Angabe des Parameters ?page=1 in der URL erstellt Django anhand der LIMIT-Klauses aus SQL eine Anfrage an die Datenbank und selektiert die ersten 10 Einträge. Festgelegt hatten wir die Anzahl der Einträge pro Seite in der EventSearchView mit der Eigenschaft paginate_by=10.

Weiterhin wird mit page_obj.has_previous geprüft, ob es eine Vorgänger Seite gibt, um einen PREV-Button anzuzeigen.

Wie man sehen kann, wird mit ?page={{ page_obj.next_page_number }} die page-Eigenschaft dynamisch gesetzt. Allerdings gehen an dieser Stelle alle anderen Querystring-Parameter verloren.

Richtig müsste unser Querystring in etwa so aussehen: ?q=as&page={{ page_obj.next_page_number }}. Wir würden also den Wert von q mit jeder Anfrage mitschleifen und würden die Suchanfrage nicht verlieren.

Puh, das war kompliziert. Im Grunde ist es aber relativ einfach. Um das Problem zu lösen, gibt es viele Wege. Wir gehen den Weg über einen eigenen Template-Tag.

das Verzeichnis für unseren Template-Tag

Legen wir unter event_manager/pages/ ein neues Verzeichnis namens templatetags an und erstellen in diesem neuen Verzeichnis eine Datei namens pagestags.py. Um daraus ein Package zu erstellen kommt auch eine __init__.py hinein.

So in etwa sollte unser Verzeichnis pages nun aussehen (nur die wichtigsten Dateien und Verzeichnisse):

pages
   ├── admin.py
   ├── models.py
   ├── templates
   ├── templatetags
   │           ├── pagestags.py
   │           ├── __init__.py
   ├── tests
   ├── urls.py
   └── ..

Die Datei pagestags.py wird der Ort, wo wir Template-Filter und Template-Tags für die Pages-App anlegen. Dieser Dateiname ist auch der Name, den wir in dem load-Tag nutzen werden, um alle Tags, die hier definiert sind, später im Template nutzen zu können.

Reminder: was sind nochmal diese Template-Tags?

Template-Tags sind Anweisungen, um den Kontrollfluss im Template zu kontrollieren, zum Beispiel das Iterieren über eine Collection oder das Verzweigen mit einer if-Abfrage.

Tags sind komplexer als Variablen: Einige erstellen Text in der Ausgabe, andere steuern den Fluss durch Ausführen von Schleifen oder Logik und einige laden externe Informationen in die Vorlage, die von Variablen verwendet werden können. Tags bieten also Logik im ansonsten statischen Rendering-Prozess.

Wir haben im Buch bisher einige Tags kennengelernt, zum Beispiel den url-Tag. {% url 'events:events' %} generiert uns den Hyperlink zur der Ausgabe aller Events.

Um eigene Tags bzw. Tags aus Drittanbieter-Packages nutzen zu können, müssen sie im jeweiligen Template mit der load-Direktive geladen werden. Unsere Pages-Tags werden mit {% load pagestags %} im Template nutzbar gemacht.

Einen eigenen Template-Tag erstellen

Wir wollen jetzt einen Template-Tag erstellen, der uns in der Paginierungslogik generell eigene GET-Parameter berücksichtigt, nicht nur von der Event-App. Deshalb erstellen wir den Template-Tag auch in der pages App.

Mehr zum Erstellen von eigenen Template-Tags und Filtern findet sich in der Django-Doku: https://docs.djangoproject.com/en/ref/howto/custom-template-tags/

In die Datei event_manager/pages/templatetags/pagestags.py schreiben wir folgenden Inhalt:

from django import template

register = template.Library()

@register.simple_tag(takes_context=True)
def safe_query_string(context, **kwargs) -> str:
   """create url-encoded string from GET-parameters.

      get querydict from GET:
      <QueryDict: {'q': ['a'], 'page': ['2']}>

      next_page:
      return q=a&page=3

      previous_page:
      return q=a&page=1
   """
   query = context['request'].GET.copy()
   for k, v in kwargs.items():
      query[k] = v
   return query.urlencode()

Wir importieren aus Django das Template-Package und erstellen die register-Variable, die eine Instanz template.Library ist. Dort sind alle Templatetags registriert. Unsere Pages-Tags aus der pagestags.py können in den Templates via {% load pagestags %} geladen und benutzt werden.

Der Tag selbst heisst safe_query_string und kann im Template genutzt in der Form {% safe_query_string %} werden. Die Funktion erstellt uns also in Abhängigkeit der aktuellen Page einen QueryString mit dem q-Wert und der page.

Überarbeiten wir das Paginator-Snippet unter event_manager/templates/snippets/paginator.html. Zuerst laden wir mit load die Template-Tags und ersetzen das Erzeugen der Links durch diese Neuerungen:

{% load pagestags %}

{% if is_paginated %}
  <nav aria-label="Page navigation conatiner"></nav>
  <ul class="pagination justify-content-center">

    {% if page_obj.has_previous %}
      <li><a href="?{% safe_query_string page=page_obj.previous_page_number %}" class="page-link">&laquo; PREV </a></li>
    {% endif %}

    {% if page_obj.has_next %}
      <li><a href="?{% safe_query_string page=page_obj.next_page_number %}" class="page-link"> NEXT &raquo;</a></li>
    {% endif %}

  </ul>
  </nav>
{% endif %}

Damit ist unsere Suche jetzt voll funktionsfähig und die Pagination sollte auch mit Suchstring funktionieren.