kleindan.dev

Klasy pomocnicze szablonów w Django

Na poprzedniej lekcji dodaliśmy do naszej aplikacji szablon, ale obsługa błędów związana z przyjmowaniem danych od użytkownika została w dużej mierze pozostawiona do własnej eksploracji. Przyjrzyjmy się teraz temu wspólnie

Obsługa błędów przetwarzania danych od użytkownika

Nasz widok obsługujący głosy zostawiliśmy w takiej postaci:

> polls/views.py
def vote(request, poll_name):
    retcode = 200
    if poll_name in poll_names:
        selected_choice = open_polls[poll_name]['options'][int(request.POST["choice"])]
        selected_choice['votes'] += 1
        return HttpResponseRedirect(reverse("polls:results", args=[poll_name]))
    else:
        return render( request, "polls/detail.html", { "poll_name": poll_name, },)

Nie trzeba się długo wczytywać, żeby znaleźć pierwszy błąd, bo optymistycznie zakładamy, że wybrana w formularzu opcja będzie istniała na liście 'options'. W przypadku danych z przesłanych przez przeglądarkę na podstawie naszeg formularza to może być prawda, ale użytkownik może też użyć np. narzędzia curl, albo może zmodyfikować formularz i w ten sposób wysłać nam nieoczekiwaną liczbę.

Podana liczba może być większa niż długość tablicy, to prawdopodobnie bardziej oczywista opcja, ale nie należy też zapomnieć o kolejnej, co jeżeli użytkownik poda liczbę mniejszą od zera? Python specyficznie zinterpretuje taką opcję, ale nie powinniśmy takiej nieoczekiwanej wartości przepuścić.

W przypadku podania nieprawidłowych danych, chcemy wyświetlić dodatkowo informację dla użytkownika, które dane naszym zdaniem się nie zgadzają.

> polls/views.py
def vote(request, poll_name):
    retcode = 200
    if poll_name in poll_names:
        user_choice = int(request.POST["choice"])
        if 0 <= user_choice < len(open_polls[poll_name]['options']):
            selected_choice = open_polls[poll_name]['options'][user_choice]
            selected_choice['votes'] += 1
            return HttpResponseRedirect(reverse("polls:results", args=[poll_name]))
        else:
            context = {
                "poll_name": poll_name, 
                "error_message": "Wybrano nieprawidłową opcję",
                "poll": open_polls[poll_name],
            }
            return render( request, "polls/poll.html", context)
    else:
        return render( request, "polls/detail.html", { "poll_name": poll_name, },)

A co, jeżeli użytkownik prześle nam formularz bez wybrania jakiejkolwiek opcji? Chwilowo formularz na to pozwala, czemu możemy zaradzić dodając do tagów <input> atrybut required.

> polls/templates/polls/poll.html
{% for choice in poll.options %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ forloop.counter0 }}" required>
    <label for="choice{{ forloop.counter }}">{{ choice.text }}</label><br>
{% endfor %}

Mimo to, musimy pamiętać, że użytkownik może zmodyfikować dokument HTML albo użyć innego narzędzia do przesłania danych do naszego serwera. Nasz kod widoku musi więc obsłużyć i taką opcję.

> polls/views.py
def vote(request, poll_name):
    retcode = 200
    if poll_name in poll_names:
        if "choice" in request.POST:
            user_choice = int(request.POST["choice"])
        else:
            user_choice = -1
        if 0 <= user_choice < len(open_polls[poll_name]['options']):
            selected_choice = open_polls[poll_name]['options'][user_choice]
            selected_choice['votes'] += 1
            return HttpResponseRedirect(reverse("polls:results", args=[poll_name]))
        else:
            context = {
                "poll_name": poll_name, 
                "error_message": "Wybrano nieprawidłową opcję",
                "poll": open_polls[poll_name],
            }
            return render( request, "polls/poll.html", context)
    else:
        return render( request, "polls/detail.html", { "poll_name": poll_name, },)

Jest nadal pewien problem. Problem dość oczywisty, jeżeli przyjmie się, że dane od użytkownika to radioaktywny pluton zawinięty w brudne bandaże i tak też będzie się z tymi danymi obchodzić. A co, jeżeli użytkownik wyśle nam dane, które w ogóle nie będą liczbami? Ten z pozoru prosty problem okazuje się dość trudny w obsłudze, jeżeli podejdzie się do niego w niewłaściwy sposób. Osoba znająca Pythona może stwierdzić, że posłuży się wbudowaną funkcją isnumeric(), ale czy wiedzieliście, że funkcja ta zwraca True także dla znaków takich jak '⅖'? Zaznaczam od razu, że funkcja rzutująca int() nie jest tak pobłażliwa i radośnie rzuci na widok takiego znaku wyjątkiem. Alternatywnie możemy użyć np. wyrażeń regularnych, a wtedy nasz kod nabiera zabawnej właściwości obsługi np. bengalskich cyfr, takich jak np. '১'.

Prawidłowym rozwiązaniem byłoby dopuszczanie tylko i wyłącznie tych opcji, które widnieją w formularzu i żadnych innych. Implementację takiego rozwiązania pozostawiam jako zadanie praktyczne do własnej realizacji.

Dodajmy do tego jeszcze obsługę sytuacji, w której użytkownik spróbuje ręcznie wejść na nasz URI do głosowania i przekierujmy go na odpowiednią stronę w takim przypadku. W takiej sytuacji nasz prosty początkowo kod wygląda już w ten sposób:

> polls/views.py
def vote(request, poll_name):
    if request.method == 'GET':
        return HttpResponseRedirect(reverse("polls:poll", args=[poll_name]))
    if poll_name in poll_names:
        if "choice" in request.POST and re.match("\d+", request.POST['choice']):
            user_choice = int(request.POST["choice"])
        else:
            user_choice = -1
        if 0 <= user_choice < len(open_polls[poll_name]['options']):
            selected_choice = open_polls[poll_name]['options'][user_choice]
            selected_choice['votes'] += 1
            return HttpResponseRedirect(reverse("polls:results", args=[poll_name]))
        else:
            context = {
                "poll_name": poll_name, 
                "error_message": "Wybrano nieprawidłową opcję",
                "poll": open_polls[poll_name],
            }
            return render( request, "polls/poll.html", context)
    else:
        return render( request, "polls/poll.html", { "poll_name": poll_name })

Prawda, że prosto? Przypominam, że nasz formularz do głosowania ma w zasadzie tylko jedno pole, w dodatku dość proste i o zamkniętej formie, “którą z podanych odpowiedzi wybierasz?”. Może jest szansa, że ktoś inny za nas to wszystko obsłuży?

Klasy pomocnicze szablonów Django

Wiele z tych problemów mogą rozwiązać za nas klasy szablonów Django. Aby ich użyć, stwórzmy nowy plik polls/forms.py i dodajmy tam następujący kod:

> polls/forms.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django import forms

class VoteForm(forms.Form):
    choice = forms.ChoiceField(
        widget=forms.RadioSelect,
        required=True,
    )

    def __init__(self, poll, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['choice'].choices = enumerate(opt['text'] for opt in poll['options'])
        self.fields['choice'].label = poll['question']

Omawianie pełnej składni Pythona w tematach działania klas oraz dziedziczenia wykracza poza zakres tego kursu, ale po krótce:

O innych rodzajach pól obsługiwanych przez Django można przeczytać w dokumentacji.

Kolejnym krokiem jest wyświetlenie naszego formularza w widoku ankiety. Zaczynając od samego szablonu:

> polls/templates/polls/poll.html
{% extends "polls/base.html" %}

{% block content %}
    {% if vote_form %}
        <form action="{% url 'polls:vote' poll_name%}" method="post">
        {% csrf_token %}
        {{ vote_form }}
        <input type="submit" value="Vote">
        </form>
    {% else %}
        <p>No such poll '{{ poll_name }}'!</p>
    {% endif %}
{% endblock %}

Zwróć uwagę, że formularz jest dodany w środku tagu HTML <form>. Nasza klasa definiuje tylko elementy formularza, ale nie jego sposób wysyłania danych czy ścieżkę, do której chcemy dane wysłać.

A następnie podając nasz formularz do funkcji render w kodzie widoku ankiety:

> polls/views.py
def by_name(request, poll_name):
    context = {
        "poll_name": poll_name,
    }
    retcode = 200
    if poll_name in poll_names:
        context['vote_form'] = VoteForm(open_polls[poll_name])
    else:
        retcode = 404
    return render(request, "polls/poll.html", context, status=retcode)

Oraz dodając obsługę naszej nowej klasy w widoku głosowania:

> polls/views.py
from .forms import VoteForm

# ...

def vote(request, poll_name):
    if request.method == 'GET':
        return HttpResponseRedirect(reverse("polls:poll", args=[poll_name]))
    if poll_name in poll_names:
        vote_form = VoteForm(poll=open_polls[poll_name], data=request.POST)
        if vote_form.is_valid():
            idx = int(vote_form.cleaned_data['choice'])
            open_polls[poll_name]['options'][idx]['votes'] += 1
            return HttpResponseRedirect(reverse("polls:results", args=[poll_name]))
        else:
            context = {
                "poll_name": poll_name, 
                "vote_form": vote_form,
            }
            return render(request, "polls/poll.html", context)
    else:
        return render(request, "polls/poll.html", { "poll_name": poll_name }, status=404)

Na pierwszy rzut oka wydaje się, że wcale tak wiele nie uzyskaliśmy, kod nie jest dużo krótszy. A jednak jeżeli się przyjrzeć, to jedyny warunek jaki musimy sprawdzić, jeżeli chodzi o nasze dane wejściowe, to vote_form.is_valid() a potem możemu już ze względnym spokojem operwać na danych z szablonu dostępnych w polu cleaned_data. Natomiast jeżeli dane nie są prawidłowe, to wystarczy ten sam formularz wyświetlić jeszcze raz, a klasa VoteForm zajmie się poinformowaniem użytkownika o problemach za nas.

Dodawanie nowych ankiet

Spróbujmy wykorzystać nowo pozyskaną wiedzę i dodajmy kolejny formularz, który posłuży nam do dodawania nowych ankiet do listy.

Zacznijmy od zdefiniowania nowego formularza jako klasy Django. Tym razem nie potrzebujemy definiować konstruktora, ponieważ każdy formularz będzie wyglądał tak samo, bez żadnych dynamicznie tworzonych elementów. Potrzebujemy 3 pól definiujących naszą ankietę:

> polls/forms.py
class NewPollForm(forms.Form):
    poll_name = forms.CharField(max_length=50, required=True)
    question = forms.CharField(max_length=500, required=True)
    choices = forms.JSONField(
        initial=["Choice 1", "Choice 2"], 
        help_text='List of choices in the form: ["choice 1", "choice 2", "choice 3"]',
        required=True)

Wszystkie pola są wymagane, a w kwestii dodawania listy dostępnych opcji posłużymy się nie najładniejszym rozwiązaniem wykorzystującym pole typu JSONField. Pozwoli nam to zdefiniować listę opcji jako JSONową tablicę stringów. W przyszłości nauczymy się lepiej obsługiwać takie scenariusze wykorzystując kod JavaScript, ale teraz takie rozwiązanie będzie dla nas wystarczające.

Dodatkowo możemy ograniczyć długość pytania i krótkiej nazwy, którą będziemy wykorzystywać do nawigowania do naszej ankiety

Dodajmy też szablon, który będzie renderował naszą stronę HTML, a następnie stwórzmy funkcję widoku wykorzystującą zarówno szablon jak i nasz nowy formularz.

> polls/templates/polls/new.html
{% extends "polls/base.html" %}

{% block content %}
    {% if error_message %}
    <div class='error'>
        <h4>{{error_message}}</h4>
    </div>
    {% endif %}
    <form action="{% url 'polls:new' %}" method="post">
    {% csrf_token %}
    {{ form }}
    <input type="submit" value="Create poll">
    </form>
{% endblock %}
> polls/views.py
def new_poll(request):
    if request.method == 'GET':
        # User wants to create new poll
        context = { 
            'form': NewPollForm()
         }
        return render(request, "polls/new.html", context)
    else:
        form = NewPollForm(request.POST)
        if form.is_valid() and choices_valid(form.cleaned_data['choices']):
            # Adding new poll based on form data
            poll_name = form.cleaned_data['poll_name']
            poll_names.append(poll_name)
            open_polls[poll_name] = {
                'question': form.cleaned_data['question'],
                'options': [
                    {
                        'text': choice, 
                        'votes': 0,
                    } for choice in form.cleaned_data['choices']
                ],
            }
            return HttpResponseRedirect(reverse("polls:vote", args=[poll_name]))
        else:
            # Errors in form, render errors to user
            context = {
                'form': form,
            }
            if form.is_valid() and not choices_valid(form.cleaned_data['choices']):
                context['error_message'] = 'Correct the form of Choices field'
            return render(request, "polls/new.html", context)

def choices_valid(choices):
    return isinstance(choices, list) and all(isinstance(val, str) for val in choices)

Nie zapomnijmy dodać naszego nowego widoku do listy obsługiwanych ścieżek.

> polls/urls.py
app_name = "polls"
urlpatterns = [
    path('', views.index, name="index"),
    path('new/', views.new_poll, name="new"),
    path('<str:poll_name>/', views.by_name, name="poll"),
    path('vote/<str:poll_name>/', views.vote, name="vote"),
    path('results/<str:poll_name>/', views.results, name="results"),
]

Teraz możemy wyświetlić nasz nowy formularz i poeksplorować jego nową funkcjonalność.
Spróbuj dodać nową ankietę, ale też powpisywać w pola jakieś nieprawidłowe wartości. Zastanów się czy i jak mógłbyś zepsuć swoją aplikację podająć złośliwe dane.

New poll form

Ostatnim krokiem jest dodanie linku, który pozwoli nam dodać nową ankietę oraz wystylizowanie naszych nowych elementów, żeby prezentowały się w jasny dla użytkownika sposób.

New poll button