Блог

Дропшиппинг интернет-магазин на Django (часть 7)

7. Визуализация данных с помощью форм и view (представлений)

Внимание! Если у вас возникли проблемы с выполнением предыдущего этапа, то вы сможете зайти в соответствующий урок, скачать архив предыдущего этапа, инсталлировать его, и начать этот урок именно с того самого места, где закончился предыдущий!

Содержание курса:

Добавить страницы: shop.html, shop-details.html и cart.html

Наш проект близится к завершению, от которого нас отделяет буквально несколько последних шагов.

Самая важная функциональная часть, на которую приходится все основные операции с базой данных, была выполнена в 5-м уроке. Т.е. по сути, вся бизнес-логика у нас уже есть и нам остаётся только добавить пользовательский интерфейс для отображения данных БД и их изменения.

Для разминки давайте сначала сделаем то, что уже хорошо умеем - скопируем из шаблона в папку templates/shop последние 3 страницы, которые там ещё остались: shop.html, shop-details.html и cart.html. И попробуем открыть их в проекта.

Для этого прежде всего уберём из них то, что уже есть в базовом шаблоне. Далее добавим в конфигуратор shop/urls.py ссылки на эти страницы.

urlpatterns = [
    path('fill-database/', views.fill_database, name='fill_database'),
    path('', TemplateView.as_view(template_name='shop/shop.html'), name='shop'),
    path('cart_view/',  
         TemplateView.as_view(template_name='shop/cart.html'), name='cart_view'),
    path('detail/<int:pk>/',  
         TemplateView.as_view(template_name='shop/shop-details.html'), 
         name='shop_detail')
]

Кстати, обратите внимание: здесь показаны не только ссылки, но и сами вью! Перед нами пример того редкого случая, когда вью можно не импортировать из модуля views.py. Дело в том, что template_name (единственно необходимый параметр для TemplateView) может быть указан в методе as_view() как kwargs-аргумент. И тогда вью “запустится” прямо из конфигуратора shop/urls.py!

И последний штрих - добавим в главное меню ссылки на страницы SHOP и CART, чтобы сразу же проверить, не допустили ли мы ошибок в вёрстке при встраивании базового шаблона. Ссылку на страницу детализации добавлять ещё некуда. Поэтому, для проверки этой страницы на отсутствие ошибок, её придётся вызывать “вручную”.

Новые типы generic view: ListView и DetailView

Знакомства с generic view мы начали в 4-м уроке, и уже тогда убедились, насколько это мощный и лаконичный инструмент Django. А из примера выше, мы также узнали, что TemplateView вообще может быть записано в одну строку прямо в конфигураторе shop/urls.py.

Два других представителя этой категории, а именно: ListView и DetailView, обладают такими же удивительными свойствами. В простейшем варианте вью можно указать только имена модели и шаблона. И этого будет вполне достаточно для того, чтобы html-шаблон получил всю необходимую информацию и сумел бы отобразить все строки таблицы Продуктов (ListView), или все поля одного единственного продукта, чей pk будет указан в конце url (DetailView).

(Справедливости ради стоит отметить, что даже имя шаблона указывать совсем необязательно - по умолчанию это имя уже содержится в настройках generic view. Дополнительную информацию можно получить здесь: Class-based views)

Создание ProductsListView

При переходе на страницу товаров по ссылке /shop/ пользователь ожидает увидеть весь список товаров. Поэтому меняем уже созданное тестовое вью TemplateView, которое было создано исключительно для проверки шаблона, на новое - ProductsListView:

from django.views.generic import ListView

from shop.models import Product


class ProductsListView(ListView):
    model = Product
    template_name = 'shop/shop.html'

Не забываем также внести изменения в конфигуратор shop/urls.py:

urlpatterns = [
    path('', views.ProductsListView.as_view(), name='shop'),
    path('cart_view/',
         TemplateView.as_view(template_name='shop/cart.html'), name='cart_view'),
    path('detail/<int:pk>/',
         TemplateView.as_view(template_name='shop/shop-details.html'),
         name='shop_detail'),
    path('fill-database/', views.fill_database, name='fill_database'),
]

По умолчанию, все данные модели Product, будут автоматически переданы в шаблон в виде объекта object_list. Поэтому, создав в html-шаблоне цикл по object_list, мы получим доступ ко всем продуктам product, а значит сможем получить значения всех интересующих нас полей:

# one block
                {% for product in object_list %}
					<div class="col-12 col-lg-4 col-md-6 item">
                        <div class="card" style="width: 18rem;">
                            <form method="post" action="">
                                <img src="{{product.image_url}}" class="card-img-top" alt="...">
                                <div class="card-body">
                                    <h5 class="card-title"><b>{{ product.name}}</b></h5>
                                    <p class="card-text">
                                        {{ product.description }}
                                    </p>
                                </div>
                                <ul class="list-group list-group-flush">
                                    <li class="list-group-item">Price: {{ product.price }}</li>
                                    <li class="list-group-item">
										{% csrf_token %}
										<label class="form-label" for="id_quantity">Quantity:</label>
										<input type="number" name="quantity" value="1" min="1"
											   required id="id_quantity"/>
                                    </li>
                                </ul>
                                <div class="card-body">
                                    <button class="learn-more-btn" type="submit">buy now</button>
                                    <a class="contactus-bar-btn f_right" href="">detail</a>
                                    <br><br>
                                </div>
                            </form>
                        </div>
                    </div>
                {% endfor %}

В шаблоне shop/shop.html сейчас находятся 4 одинаковых тестовых блока. Заменив один из них на предложенный вариант, мы получим полных список всех блоков со всем значениями таблицы продукт.

Такое же лаконичное и изящное решение существует в Django и для отображения отдельно выбранного объекта. Только теперь наследуется не ListView, а DetailView:

class ProductsDetailView(DetailView):
    model = Product
    template_name = 'shop/shop-details.html'

И не забываем изменить имя вью в shop/urls.py:

urlpatterns = [
    path('', views.ProductsListView.as_view(), name='shop'),
    path('cart_view/',
         TemplateView.as_view(template_name='shop/cart.html'), name='cart_view'),
    path('detail/<int:pk>/', views.ProductsDetailView.as_view(),
         name='shop_detail'),
    path('fill-database/', views.fill_database, name='fill_database'),
]

Теперь, когда отображается реальный список продуктов, мы можем добавить ссылку на страницу детализации в цикл отображения продуктов product в шаблоне shop/shop.html:

<div class="card-body">
	<button class="learn-more-btn" type="submit">buy now</button>
	<a class="contactus-bar-btn f_right" href="{% url 'shop_detail' product.pk %}">detail</a>
	<br><br>
</div>

Обратите внимание, как создаётся новая составная ссылка {% url 'shop_detail' product.pk %}: через пробел от имени url идёт номер продукта в БД - product.pk. Разумеется, этот результат тоже необходимо проверить.

Добавляем выбранную позицию в корзину

Вот мы и подошли к одному из самых ответственных моментов - к наполнению корзины выбранным товаром. Разумеется, и эту логику также можно реализовать с помощью generic view. Более того, этот способ считается предпочтительным, поскольку, как известно, чем сложнее задача, тем более понятно и лаконично выглядит код generic view, по сравнению с кодом обычной функции.

Но, такой вариант может оказаться не очень понятным для тех, кто пока ещё на “вы” с объектно-ориентированным программированием. Поэтому, для решения этой задачи снова вернёмся к функциям и формам.

Начнём с формы. Заполнять нам предстоит таблицу OrderItem, которая по ForeignKey связана с таблицами (моделями) Product и Order. Поэтому, по сути, единственным неизвестным полем, которое нам предстоит ввести - это поле количества Quantity, а все остальные данные мы можем взять из других таблиц. Поэтому наша форма AddQuantityForm будет состоять только из одного поля:

from django import forms

from shop.models import OrderItem


class AddQuantityForm(forms.ModelForm):
    class Meta:
        model = OrderItem
        fields = ['quantity']

Теперь вернёмся к вью, которое назовём add_item_to_cart. И здесь наша задача предельно упрощается - нам не нужно создавать GET-запрос. Ведь с этим отлично справляется ProductListView. Поэтому всё, что требуется от вью add_item_to_cart - это получить и обработать POST-запрос:

@login_required(login_url=reverse_lazy('login'))
def add_item_to_cart(request, pk):
    if request.method == 'POST':
        quantity_form = AddQuantityForm(request.POST)
        if quantity_form.is_valid():
            quantity = quantity_form.cleaned_data['quantity']
            if quantity:
                cart = Order.get_cart(request.user)
                # product = Product.objects.get(pk=pk)
                product = get_object_or_404(Product, pk=pk)
                cart.orderitem_set.create(product=product,
                                          quantity=quantity,
                                          price=product.price)
                cart.save()
                return redirect('cart_view')
        else:
            pass
    return redirect('shop')

Как видим, если форма прошла валидацию, то создаётся объект quantity. Помним также, что все объекты OrderItem существуют не сами по себе, а обязательно привязаны к какой-то корзине или заказу. Метод get_cart, который мы уже создали на предыдущих занятиях, способен обеспечить нас нужной корзиной - объектом cart. Объект product, чьё количество мы только что подтвердили, леко получается по запросу для pk=pk. Кстати, product можно сделать с помощью метода get(), но более надёжным считается вариант get_object_or_404(), который сможет обработать ошибку 404, если объекта с искомым pk не окажется в базе данных.

Таким образом, все поля, необходимые для создания нового объекта, мы уже получили. Поэтому теперь с помощью cart.orderitem_set.create() создаём новый объект модели OrderItem, а с помощью метода cart.save(), фиксируем связь этого объекта с корзиной заказа.

Последнее, что нам теперь остаётся, это добавить новый url в конфигуратор:

path('add-item-to-cart/<int:pk>', views.add_item_to_cart, name='add_item_to_cart'),

и затем добавить этот новый url в атрибут action тега формы на странице shop/shop.html:

<form method="post" action="{% url 'add_item_to_cart' product.pk %}">

Теперь всё готово к добавлению заказов в корзину. Правда, увидеть результат мы пока сможем лишь в админке.

И очень важный штрих, который едва не остался за кадром. Смотреть на каталог продукции должны иметь возможность все пользователи. А вот выбирать товар и добавлять его в корзину могут только зарегистрированные. Решить задачу защиты вью add_item_to_cart, от несанкционированного доступа неавторизованных пользователей, поможет декоратор @login_required. Как видим, этот декоратор автоматически перенаправит не залогиненного пользователя на страницу авторизации 'login'.

Управление корзиной: отображение списка элементов

Конечно же, добавленные в корзину позиции хотелось бы видеть не только в админке. Тем более, что у нас уже всё для этого готово. Кроме вью. Им и займёмся.

По сути, для отображения элементов корзины необходимо получить данные двух моделей:

  • Модели заказа Order (из которой с помощью метода get_cart(user) получаем объекта cart)
  • И модели OrderItem (для order=cart)

И затем передать эти данные в шаблон с помощью словаря context:

@login_required(login_url=reverse_lazy('login'))
def cart_view(request):
    cart = Order.get_cart(request.user)
    items = cart.orderitem_set.all()
    context = {
        'cart': cart,
        'items': items,
    }
    return render(request, 'shop/cart.html', context)

Из объекта cart в шаблоне будут извлекаться только данные, относящиеся к самой корзине (в нашем случае - только сумма заказа). Данные по каждой позиции заказа item мы получим результате цикла по объекту items:

{% for item in items %}
    <div class="row">
        <div class="col-12 col-md-1 item">
            &nbsp;&nbsp;&nbsp;{{ forloop.counter }}
        </div>
        <div class="col-12 col-md-4 item">
            {{ item.product }}
        </div>
        <div class="col-12 col-md-2 item">
            {{ item.quantity }}
        </div>
        <div class="col-12 col-md-2 item">
            {{ item.price }}
        </div>
        <div class="col-12 col-md-2 item">
            {{ item.amount }}
        </div>
        <div class="col-12 col-md-1 item">
        </div>
    </div>
{% endfor %}

Управление корзиной: удаление позиций

Ошибаться может каждый. Поэтому пользователь должен иметь возможность удалять лишние позиции из корзины.

Как мы уже хорошо усвоили - все изменения базы данных должны проходить только через форму и метод POST. И удаление позиции в том числе.

Здесь стоит отметить, что для удаления элементов модели в Django имеется очень удобное generic view - DeleteView, для которого не нужно ни создавать отдельную форму в модуле shop/forms.py, ни специально описывать метод POST. Всё это уже создано в DeleteView по умолчанию:

@method_decorator(login_required, name='dispatch')
class CartDeleteItem(DeleteView):
    model = OrderItem
    template_name = 'shop/cart.html'
    success_url = reverse_lazy('cart_view')

    # Проверка доступа
    def get_queryset(self):
        qs = super().get_queryset()
        qs.filter(order__user=self.request.user)
        return qs

Единственное, что мы здесь добавили (точнее, изменили), так это метод get_queryset, которые фильтрует запрос данных модели OrderItem по пользователю user.

Никакого дополнительно вывода по методу GET нам тоже не нужно: как и в случае с добавлением позиции в корзину, мы воспользуется готовыми данными, которые нам любезно предоставляет cart_view.

Всё, что нам остаётся - это добавить форму в КАЖДУЮ (!!!) позицию корзины (благо, все они всё равно выводятся в цикле) и новый url для вызова нового вью CartDeleteItem.

Изменения в шаблоне shop/cart.html:

<div class="col-12 col-md-1 item">
    <form method="post" action="{% url 'cart_delete_item' item.pk %}">
        {% csrf_token %}
        <button type="submit" style="color: blue"><u>delete</u></button>
    </form>
</div>

Добавление в shop/urls.pyl:

path('delete_item/<int:pk>', views.CartDeleteItem.as_view(), name='cart_delete_item'),

Управление корзиной: переход к созданию заказа

После того, как всё нужное будет успешно добавлено в корзину, а всё лишнее будет благополучно из неё удалено, всё, что нам остаётся - завершить процесс набора, изменить статус корзины с STATUS_CART на STATUS_WAITING_FOR_PAYMENT и тем самым перейти к оплате заказа.

Сам метод make_order у нас давно уже создан. Остаётся только добавить на лист корзины кнопку, по нажатию который будет запускаться этот метод.

Задача для вью будет очень простая - найти нужную корзину и применить к ней метод make_order:

@login_required(login_url=reverse_lazy('login'))
def make_order(request):
    cart = Order.get_cart(request.user)
    cart.make_order()
    return redirect('shop')

После смены статуса произойдёт редирект на страницу shop/shop.html. После подключения онлайн оплаты, этот редирект можно будет заменить переходом на страницу агрегатора платежей. И ещё надо будет не забыть добавить в shop/urls.py новую ссылку:

path('make-order/', views.make_order, name='make_order'),

И добавить эту ссылку в кнопку на страницу корзины:

<a class="contactus-bar-btn f_right" href="{% url 'make_order' %}">
    Process to Payment
</a>

Заключение

Наш проект завершён. Конечно же многое осталось за кадром: подтверждение регистрации по имейл, логирование, подключение онлайн оплаты, деплоинг и т.д. и т.п.

Тем не менее, главные функционал интернет-магазина создан. А улучшению, как известно, никогда не бывает ни конца, ни края.

В любом случае, если возникнут вопросы, Вы знаете кому их можно задать: it4each.com@gmail.com.

Успеха в создании собственного интернет-магазина и до встречи на новых курсах!

Более подробно с со всеми деталями этого этапа вы сможете познакомиться из этого видео: