Widoki klasowe w Django

9 minut(y)

W dzisiejszym wpisie chciałbym Wam przedstawić zagadnienia związane z wykorzystaniem widoków klaswych w Django.

Wzorzec MTV

Framework Django zbudowany jest w oparciu o wzorzec projektowy MTV (Model-Template-View, Model-Szablon-Widok). Jest to nic innego jak klasyczny model MVC, w którym stosuje sie tylko inne nazewnictwo poszczególnych komponentów. Rolę klasycznych widoków (Views) pełnią w Django szablony (Templates), a rolę kontrolerów - widoki (Views). W typowej aplikacji Django żądania są przetwarzane na podstawie pliku konfiguracyjnego urls.py. Framework parsuje adres URL przychodzącego żądania i przekazuje dane do różnych modułów. Te moduły zawierają w sobie widoki, modele i szablony. Gdy django tworzy nowy moduł (aplikację) tworzy folder z nazwą tej aplikacji i w nim umieszcza startową strukturę plików:

.
├── books
│   ├── migrations
│   │   └── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── project
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

Zwrócić należy uwagę, że folder dla szablonów (templates) nie jest domyślnie tworzony w katalogu aplikacji. Wynika to z faktu, że widoki w (views.py) mogą być używane bezpośrednio do renderowania zawartości strony HTML. Jednak dobrą praktyką jest aby szablony HTML umieszczać w wydzielonym do tego celu folderze.

Widoki w działaniu

W bardzo dużym uproszczeniu Django jest to zbiór komponentów, które umożliwiają przyjmowanie zapytań webowych i zwracanie odpowiedzi w postaci kompletnego kodu strony HTML dla przeglądarki internetowej. Punktem wejścia są tutaj URLe zapytań webowych. To na ich podstawie Django przekazuje te zapytania do odpowiednich widoków.

Sam widok jest funkcją języka Python, która jest wykonywana gdy Django otrzyma żądanie z odpowiednim URLem. Widoki mogą być bardzo proste, zwracać tylko zwykły tekst jako odpowiedź albo mogą być dużo bardziej skomplikowane i na przykład pobierać dane z bazy danych, przetwarzać formularze itp. Bez względu na stopień skomplikowania funkcji widoku, po zakończeniu jej działania zwrócony rezultat zostaje przekazany jako odpowiedź na żądanie.

Przykład widoku funkcyjnego

# views.py
def book_update_view(request):
  if request.method == 'GET':
     # kod do obsługi żądania GET

  if request.method == 'POST':
     # kod do obsługi żądania POST
# urls.py
urlpatterns = [
  path('update/<int:pk>', views.book_update_view, name='update')
]

Przykład widoku klasowego

# views.py
from django.views import View

class BookUpdateView(View):
    def get(self, request, *args, **kwargs):
        # kod do obsługi żądania GET

    def post(self, request, *args, **kwargs):
        # kod do obsługi żądania POST
# urls.py
urlpatterns = [
    path('update/<int:pk>', views.BookUpdateView.as_view(), name='update')
]

Korzystając z widoków klasowych bezpośrednio lub pośrednio (o czym za chwilę) rozszerzamy klasę django.views.View, która dostarcza nam metodę dispatch(). Metoda dispatch() zawiera logikę obsługi dla poszczególnych metod zapytań HTTP. Jeśli otrzymaliśmy żądanie metodą GET to zostaje wywołana metoda get() widoku, dla żądania POST nastąpi wywołanie metody post() widoku. Analogiczne wywołania metod nastąpią dla innych metod żądań np. PUT, PATCH.

.as_view() ===> .dispatch() -----------------------------------------------------
                                |      |      |       |       |    ...          |
                                v      v      v       v       v                 v
                              get()  post()  put()  patch()  head()    http_method_not_allowed()

Klasy widoków bazujące na django.view.Views posiadają specjalną metodę as_view(), dzięki której “opakowujemy” naszą klasę w funkcję i możemy przekazać ją do URL resolvera (path()). W kodach źródłowych projektów związanych z Django bazujących na widokach klasowych można jeszcze często spotkać konstrukcję jak poniżej:

# views.py
from django.views import View

class BookUpdateView(View):
    def get(self, request, *args, **kwargs):
        # kod do obsługi żądania GET

    def post(self, request, *args, **kwargs):
        # kod do obsługi żądania POST

book_update_view = BookUpdateView.as_view()

# urls.py
urlpatterns = [
    path('update/<int:pk>', views.book_update_view, name='update')
]

Plusy i minusy

Widoki klasowe podobnie, jak i widoki funkcyjne mają swoje wady i zalety. To, które z nich stosować w projekcie zależy od potrzeb i możliwości. Widoki klasowe nie zastępują w pełni starszych widoków funkcyjnych i z pewnością istnieje wiele przykładów gdzie każde z nich wypadają lepiej niż te drugie. Nie ma złotej zasady, która mówi, w jakich sytuacjach należy stosować widoki funkcyjne, a w jakich klasowe. W społeczności programistów Django zdania są podzielone. Osobiście w większości używam widoków klasowych, ze względu na to że pozwalają mi tworzyć prostszy i lepiej zorganizowany kod widoków. Mogę przytoczyć przykład z własnego doświadczenia, gdzie widoki funkcyjne sprawdziły się lepiej. Była to sytuacja w której jeden widok musiał obsłużyć równocześnie dwa formularze.

Widoki Plusy Minusy
Funkcyjne proste w implementacji trudne w rozbudowie i w ponownym użyciu
  czytelny kod obsługa metod HTTP poprzez warunki rozgałęziające
  łatwe korzystanie z dekoratorów  
Klasowe łatwe w rozbudowie trudniejsze w zrozumieniu
  można używać technik programowania obiektowego (wielokrotne dziedziczenie) niejawny code flow
  obsługa metod HTTP w oddzielnych metodach widoku używanie dekoratorów wymaga dodatkowych importów lub nadpisywania metod
  wbudowane generyczne widoki klasowe i mixiny  

Widoki generyczne i klasy pomocnicze (mixins)

Generyczne widoki klasowe są to abstrakcyjne klasy implementujące typowe zadania związane z tworzeniem aplikacji webowych takie jak tworzenie i aktualizacja obiektów, obsługa formularzy, tworzenie list, stronicowanie, widoki archiwum itp. Są to gotowe do wykorzystania (django.views.generic), łatwo rozszerzalne narzędzia oparte na wielokrotnym dziedziczeniu, które w sposób znaczący mogą przyśpieszyć szybkość tworzenia aplikacji i ograniczyć złożoność kodu.

Do dyspozycji mamy cały szereg widoków i mixinów. Z pozoru są to proste klasy, ale skrywają one w swoich implementacjach wielu przodków oraz zależności. Czasami trzeba poświęcić nieco uwagi na analizę lub poznanie przepływu kodu, bo nie zawsze wszystko jest oczywiste.

Weźmy dla przykładu sytuację, w której znajdziemy się rozwijając przykładową aplikację do katalogowania naszych pozycji książkowych. Mamy dany model reprezentujący pojedynczą książkę:

# books/models.py
from django.db import models

class Book(models.Model):
    name = models.CharField(max_length=200)
    author = models.CharField(max_length=200)
    read = models.BooleanField(default=False)

    def __str__(self):
        return self.name

i teraz chcielibyśmy napisać widok do listowania naszej biblioteczki. Widok ten oprzemy na generycznym widoku ListView

# books/views.py
from django.views import generic

from . models import Book

class BookListView(generic.ListView):
    model = Book
# books/urls.py
from django.urls import path
from . import views
app_name = 'books'

urlpatterns = [
    path('', view=views.BookListView.as_view(), name='book_list'),
]

i to w zasadzie wszystko. No może prawie wszystko, bo jeszcze powinniśmy utworzyć szablon HTML, który tą listę nam wyświetli:

# books/templates/books/book_list.html
...
<h5>Lista książek</h5>
<ul>
{% for book in object_list %}
<li>{{book.name}} - {{book.author}}</li>
{% endfor %}
</ul>
...

Ale po kolei. W kodzie widoku widać wyraźnie, że opiera się on na klasie ListView i do właściwości model przypisuje model Book. Dzięki temu widok pobierze nam listę obiektów Book z bazy danych i przekaże ją w do szablonu. Uważny czytelnik zauważy, że wspomniana lista obiektów w szablonie dostępna jest pod nazwą object_list. Taka nazwa jest przyjmowana domyślnie, gdy w kodzie widoku nie określimy jak lista obiektów ma się nazywać.

Pozostaje jeszcze do rozstrzygnięcia kwestia lokalizacji i nazwy pliku z szablonem HTML. W kodzie widoku nie określiliśmy jawnie ścieżki z nazwą szablonu, więc szablon będzie poszukiwany wg wzorca {nazwa_aplikacji}/{nazwa_modelu}_{suffix}.html (dla ListView suffix ma ustawioną wartość właśnie na list).

W przypadku, gdyby nasza aplikacja była wyposażona w system autoryzacji użytkowników i chcielibyśmy ograniczyć możliwość wylistowania książek tylko dla zalogowanych użytkowników, to z pomocą przychodzi nam klasa pomocnicza (mixin) LoginRequiredMixin. Wystarczy wymienić ją jako jedną z klas przodków w definicji klasy BookListView (koniecznie z lewej strony, przed widokiem generycznym). Teraz niezalogowani użytkownicy, którzy zażądają tego zasobu zostaną przekierowani do strony logowania.

# books/views.py
from django.views import generic
from django.contrib.auth.mixins import LoginRequiredMixin

from . models import Book

class BookListView(LoginRequiredMixin, generic.ListView):
    model = Book

Obsługa form

Idźmy dalej i rozważmy teraz sytuację, w której chcemy napisać funkcjonalność edycji istniejącej książki. Do tego celu będziemy potrzebować formularz i odpowiedni widok. Zaczniemy od przygotowania formularza (Form), czyli komponentu Django, który będzie odpowiedzialny m.in. za przygotowanie kodu HTML umożliwiającego użytkownikowi przeprowadzenie edycji danych. Tworząc kod formularza nie ma znaczenia, czy korzystamy z widoków funkcyjnych czy klasowych.

from django import forms

from .models import Book

class BookForm(forms.ModelForm):

    class Meta:
        model = Book
        fields = ('name', 'author', 'read')

Teraz napiszemy kod klasy widoku. Klasę BookUpdateView oprzemy na widoku generycznym UpdateView. Poprzez właściwość form_class wskażemy widokowi jakiego formularza ma użyć, a w success_url obowiązkowo umieścimy URL do przekierowania w przypadku pomyślnego zapisu danych o książce.

# books/views.py
from django.urls import reverse_lazy
from django.views import generic

from . models import Book
from .forms import BookForm

class BookListView(generic.ListView):
    model = Book

class BookUpdateView(generic.UpdateView):
    model = Book
    form_class = BookForm
    success_url = reverse_lazy('books:book_list')

Teraz tworzymy kod szablonu HTML, który wygeneruje dla przeglądarki wszystko co potrzebne.

# books/templates/books/book_form.html
...
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <input type="submit"value="Zapisz">
</form>
...

Na zakończenie umieszczamy odpowiedni pozycję w urls.py.

# books/urls.py
...
urlpatterns = [
    path('', view=views.BookListView.as_view(), name='book_list'),
    path('edit/<int:pk>', view=views.BookUpdateView.as_view(), name='book_edit'),
]
...

I wszystko gotowe. Omówię pokrótce po kolei co się dzieje w całym procesie edycji danych książki. Zaczniemy od wpisania w pasku przeglądarki adresu URL http://127.0.0.1:8000/books/edit/3. Teraz następuję wysłanie żądania metodą GET do naszej aplikacji. Komponent Django zwany Url Resolverem przeparsuje ścieżkę żądania i na tej podstawie podejmie decyzję, że do obsługi tego żądania odpowiedni będzie widok BookUpdateView. Resolver “uruchomi” ten widok, przekazując mu stosowne parametry (obiekt request, wartość pk reprezentującą klucz główny książki do edycji jako argument nazwany (keyword argument)).

Teraz do akcji wkracza metoda .dispatch(), która na podstawie rodzaju żądania (w tym wypadku GET) wywoła metodą .get() naszego widoku. Patrząc obecnie na kod widoku BookUpdateView nie znajdziemy w nim definicji metody get(). Nic nie szkodzi, definicja tej metody (podobnie jak i m.in. post()) zawarta jest w przodkach klasy widoku generycznego django.views.generic.UpdateView.

Metoda get() na podstawie parametru pk pobierze nam z bazy danych instancję książki (Book). Na podstawie wartości właściwości form_class utworzy nam formę BookForm i przekaże jej dane o instancji książki. W kolejnym kroku wyrenderuje szablon HTML book_form.html i wyrenderowany kod HTML zwróci jako response.

Teraz użytkownik dokonuje edycji danych w przeglądarce i klika przycisk Zapisz. Teraz przeglądarka generuje żądanie POST do tego samego adresu URL co poprzednio, które dodatkowo zawiera zaktualizowane dane o książce.

Metoda dispatch() rozstrzyga, że tym razem wywoła metodę post(). Metoda post() (podobnie jak wcześniej get()) na podstawie parametru pk pobiera z bazy danych instancję książki, tworzy formę BookForm tym razem na podstawie wartości otrzymanych w żądaniu. Następnie dokonywana jest walidacja danych tej formy. Jeśli walidacja przebiegła pomyślnie to dane są zapisywane w bazie danych i jako odpowiedź HTTP zwracany jest kod 302 z adresem success_url.

W przypadku błędów walidacji renderowany jest szablon HTML book_form.html i wyrenderowany kod HTML zwracany jako odpowiedź HTTP. W kontekście (context), przekazywanym do szablonu zawarte są informacje o błędach walidacji, które to są wyświetlane w formularzu HTML po stronie użytkownika.

Te rzeczy, o których piszę w kilku powyższych akapitach, działają niejako automatycznie i te działania wynikają z konstrukcji widoku generycznego UpdateView. Dla innych widoków generycznych działanie będzie nieco inne. Najważniejsze jest jednak to, że mamy gotowe widoki do wykorzystania na prawie każdą okazję i nie musimy sami tworzyć pewnych powtarzalnych fragmentów kodu. Konstrukcja widoków generycznych została tak pomyślana, aby w łatwy sposób można było dostosować ich działanie do własnych potrzeb poprzez np. nadpisanie pewnych metod, czy nadanie odpowiednich wartości właściwościom widoku.

Dekoratory - jak używać w widokach klasowych

W przypadku korzystania z widoków klasowych z dekoratorów możemy korzystać na trzy sposoby.

1. W konfiguracji szablonów URL w plikach urls.py

from django.contrib.auth.decorators import login_required, permission_required
from django.urls import path

from . import views
app_name = 'books'

urlpatterns = [
    path('', view=login_required(views.BookListView.as_view()), name='book_list'),
    path('edit/<int:pk>', view=permission_required('books.can_edit')(views.BookUpdateView.as_view())), name='book_edit'),
]

2. Dekorowanie metody dispatch()

Metoda dispatch() jako punkt wejścia do każdego widoku klasowego może zostać udekorowana za pomocą wrapera @method_decorator. Wrapper ten jest konieczny do zastosowanie ponieważ metoda klasy to nie jest dokładnie to samo co funkcja w języku Python, dlatego np. dekorator @login_required nie może być tutaj użyty bezpośrednio.

class BookListView(generic.ListView):
    model = Book

    @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

W sytuacji gdy potrzebujmy udekorować widok więcej niż jednym dekoratorem możemy to zrobić za pomocą listy bądź krotki dekoratorów

    decorators = ('permission_1', 'persmission_2')
    @method_decorator(decorators)
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

3. Dekorowanie klasy widoku i wskazanie metody która ma zostać udekorowana

@method_decorator(login_required, name='dispatch')
class BookListView(generic.ListView):
    model = Book
    ...

Analogicznie dla kilku dekoratorów

decorators = [user_is_editor, login_required]
@method_decorator(decorators, name='dispatch')
class BookListView(generic.ListView):
    model = Book
    ...

lub

@method_decorator(user_is_editor, name='dispatch')
@method_decorator(login_required, name='dispatch')
class BookListView(generic.ListView):
    model = Book
    ...

Trzeciego sposobu należy używać, gdy potrzebujemy w różny sposób udekorować różne metody widoku.

Dla osób zainteresowanych przygotowałem kompletny kod projektu który wykorzystywałem w tym wpisie. Zapraszam oczywiście do dyskusji i komentarzy na poruszane tutaj tematy.

Zostaw komentarz