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 werdenDas Input-Feld hat das
name
-Attribut mit dem Wertq
.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:
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">« PREV </a></li>
{% endif %}
{% if page_obj.has_next %}
<li><a href="?page={{ page_obj.next_page_number }}" class="page-link"> NEXT »</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.
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">« 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 »</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
Damit ist unsere Suche jetzt voll funktionsfähig und die Pagination sollte auch mit Suchstring funktionieren.