Внимание! Если у вас возникли проблемы с выполнением предыдущего этапа, то вы сможете зайти в соответствующий урок, скачать архив предыдущего этапа, инсталлировать его, и начать этот урок именно с того самого места, где закончился предыдущий!
Содержание курса:
Наш проект близится к завершению, от которого нас отделяет буквально несколько последних шагов.
Самая важная функциональная часть, на которую приходится все основные операции с базой данных, была выполнена в 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 мы начали в 4-м уроке, и уже тогда убедились, насколько это мощный и лаконичный инструмент Django. А из примера выше, мы также узнали, что TemplateView вообще может быть записано в одну строку прямо в конфигураторе shop/urls.py.
Два других представителя этой категории, а именно: ListView и DetailView, обладают такими же удивительными свойствами. В простейшем варианте вью можно указать только имена модели и шаблона. И этого будет вполне достаточно для того, чтобы html-шаблон получил всю необходимую информацию и сумел бы отобразить все строки таблицы Продуктов (ListView), или все поля одного единственного продукта, чей pk будет указан в конце url (DetailView).
(Справедливости ради стоит отметить, что даже имя шаблона указывать совсем необязательно - по умолчанию это имя уже содержится в настройках generic view. Дополнительную информацию можно получить здесь: Class-based views)
При переходе на страницу товаров по ссылке /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'.
Конечно же, добавленные в корзину позиции хотелось бы видеть не только в админке. Тем более, что у нас уже всё для этого готово. Кроме вью. Им и займёмся.
По сути, для отображения элементов корзины необходимо получить данные двух моделей:
И затем передать эти данные в шаблон с помощью словаря 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">
{{ 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.
Успеха в создании собственного интернет-магазина и до встречи на новых курсах!
Более подробно с со всеми деталями этого этапа вы сможете познакомиться из этого видео: