Блог

Дропшиппинг интернет-магазин на 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.

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

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





Читать дальше >>

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

6. Добавление модуля скрейпинга (парсинга) и автозаполнение БД прямо из интернета!

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

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

Как получить информацию с другого сайта?

Конечно же, самым удобным (и, кстати, более надёжным) способом будет взаимодействие нашего сайта с сайтом-поставщиком по API. Правда, для этого необходимо, чтобы

  • на сайте-поставщике было организована такой возможность (доступа к нему по API),
  • и чтобы администрация сайта-поставщика выдала нам логин и пароль для этого доступа.

И хотя фреймворк Django позволяет организовать подобное взаимодействия собственными средствами, не прибегая к установке сторонних пакетов, тем не менее, лучше всего с решением этой задачи справится пакет Django REST ftamework (DRF), который обязательно будет рассмотрен в одном из наших ближайших курсов.

Однако, в нашем случае мы воспользуемся другим способом - считыванием необходимой информации непосредственно с HTML-страницы стороннего сайта. Это действие носит название скрейпинг (парсинг) сайта.

Для этой цели будут использованы две популярные Python библиотеки: beautifulsoup4 и requests. Установить их можно с помощью двух терминальных команд:

pip install beautifulsoup4
pip install requests

Структура веб страницы

Обычно данные на странице товаров сгруппированы в блоки. Внутри блоков однотипные данные находятся под одинаковыми селекторами (см. рисунок):

Если скачать и “распарсить” HTML-страницу со списком товаров, мы можем получить структурированный список данных. Конкретно для нашего случая по каждому блоку данных нам нужно получить вот такой словарь:

{
    'name': 'Труба профильная 40х20 2 мм 3м', 
    'image_url': 'https://my-website.com/30C39890-D527-427E-B573-504969456BF5.jpg', 
    'price': Decimal('493.00'), 
    'unit': 'за шт', 
    'code': '38140012'
 }

План действий

  • Создать модуль scraping.py в приложении shop
  • В этом модуле создать функцию scraping(), которая сможет:
    1. Получить HTML-код страницы (с помощью request)
    2. Обработать полученный HTML-код (с помощью beautifulsoup4)
    3. Сохранить результат в базе данных
  • Проверить работу функции scraping() “вручную”
  • Добавить кнопку запуска скрейпинга на страницу сайта

План готов - приступаем к его реализации!

Создаём модуль скрейпинга (парсинга) и получаем HTML-код с помощью пакета requests

Очевидно, что скрипт, отвечающий за считывание информации с другого сайта следует разместить в отдельном модуле приложения shop: shop/scraping.py. За отправку запроса по адресу URL_SCRAPING, считывание данных и запись этих данных в таблицу Product базы данных проекта, будет отвечать функция scraping().

Прежде всего нам потребуется получить HTML-код страницы с данными по продукции для дальнейшей обработки. Выполнение этой задачи будет поручено модулю requests:

import requests

def scraping():
    URL_SCRAPING = 'https://www.some-site.com'
    resp = requests.get(URL_SCRAPING, timeout=10.0)
    if resp.status_code != 200:
        raise Exception('HTTP error access!')

    data_list = []
    html = resp.text

Есть смысл сразу же посмотреть, что получили. Для этого добавим код, который подсчитает число символов в объекте html и заодно выведет на печать сам этот объект:

html = resp.text
    print(f'HTML text consists of {len(html)} symbols')
    print(50 * '=')
    print(html)


if __name__ == '__main__':
    scraping()

Модуль shop/scraaping.py не требует настроек Django (во всяком случае пока), поэтому запустить его можно как обычный Python-скрипт:

HTML text consists of 435395 symbols
==================================================
<!DOCTYPE html>
<html lang="ru">
  <head>
    <link rel="shortcut icon" type="image/x-icon" href="/bitrix/templates/elektro_light/favicon_new.ico"/>
    <meta name="robots" content="index, follow">
<meta name="keywords" content="Профильные трубы, уголки">
<meta name="description" content="Цены на профильные трубы, уголки от  руб. Описание. Характеристики. Отзывы. Скидки на  профильные трубы, уголки.">
    <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no"/>
    <meta name="msapplication-TileColor" content="#ffffff">

Как видим результат действительно похож на HTML-страницу.

Первая часть задачи решена - доступ к данным сайта получен, и в тех 435 395 символах, что вывелась на экран, содержится вся необходимая нам информация. Всё, что нам теперь требуется - просто извлечь эту информацию и сохранить результат в БД.

Обрабатываем полученный HTML-код с помощью пакета BeautifulSoup

Дальнейшую обработку удобнее всего будет вести с помощью модуля beautifulsoup4. Для этого нам потребуется сначала создать объект soup, представляющий собой вложенную структуру данных HTML-документа:

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, 'html.parser')

Более подробную информацию о том, как начать работать с этим пакетом можно найти на странице руководства: BeautifulSoup #quick-start

Также очень полезной может быть дополнительная информация о CSS-селекторах пакета beautifulsoup4, которую можно прочитать здесь: BeautifulSoup #css-selectors

Далее на странице поставщика более всего нас будет интересовать блок товаров - вёрстка повторяющихся карточек товара, имеющих сходную структуру данных. Получить список однотипных элементов из объекта soup можно с помощью метода select(), где в качестве аргумента указан CSS-selector этого блока. В нашем случае это будет class=”catalog-item-card”:

blocks = soup.select('.catalog-item-card ')

В цикле мы можем получить доступ к каждому блоку, и заодно посмотреть, что находится внутри объекта block. Так станет выглядеть изменённый код:

html = resp.text

    soup = BeautifulSoup(html, 'html.parser')
    blocks = soup.select('.catalog-item-card ')

    for block in blocks:
        print(f'HTML text consists of {len(block.text)} symbols')
        print(50 * '=')
        print(block.text)
        break

А вот так будет выглядеть распечатанный объект block.text:

HTML text consists of 382 symbols
==================================================
<div class="catalog-item-card" itemprop="itemListElement" itemscope="" itemtype="http://schema.org/Product">
<div class="catalog-item-info">
<div class="item-all-title">
<a class="item-title" href="/catalog/profilnye_truby_ugolki/truba_profilnaya_40kh20_2_mm_3m/" itemprop="url" title="Труба профильная 40х20 2 мм 3м">
<span itemprop="name">Труба профильная 40х20 2 мм 3м</span>
</a>

Как видим, число символов в блоке сократилось до 382-х. Что существенно упрощает нашу задачу.

Мы можем “разобрать” эти блоки на интересующие нас элементы с помощью метода soup.select_one(), который, в отличие от метода select(), выбирает не все элементы страницы, удовлетворяющее условию (аргументу метода), а только первый совпавший элемент. Важно также помнить, что текст, из полученного с помощью soup.select_one() объекта, можно извлечь с помощью метода text. Таким образом, применяя этот метод с определёнными аргументами, мы заполняем практически весь словарь данных, за исключением поля code:

soup = BeautifulSoup(html, 'html.parser')
    blocks = soup.select('.catalog-item-card ')

    for block in blocks:
        """{
        'name': 'Труба профильная 40х20 2 мм 3м', 
        'image_url': 'https://my-website.com/30C39890-D527-427E-B573-504969456BF5.jpg', 
        'price': Decimal('493.00'), 
        'unit': 'за шт', 
        'code': '38140012'
        }
        """
        data = {}
        name = block.select_one('.item-title[title]').get_text().strip()
        data['name'] = name

        image_url = URL_SCRAPING_DOMAIN + block.select_one('img')['src']
        data['image_url'] = image_url

        price_raw = block.select_one('.item-price ').text
        # '\r\n \t\t\t\t\t\t\t\t\t\t\t\t\t\t493.00\t\t\t\t\t\t\t\t\t\t\t\t  руб. '
        price = re.findall(r'\S\d+\.\d+\S', price_raw)[0]
        price = Decimal(price)
        data['price'] = price   # 493.00

        unit = block.select_one('.unit ').text.strip()
        # '\r\n \t\t\t\t\t\t\t\t\t\t\t\t\t\tза шт\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'
        data['unit'] = unit  # 'за шт'

Формируем дополнительную ссылку для перехода на страницу детализации и получение кода продукта

При детальном рассмотрении блока товаров block оказалось, что часть информации по товару находится на другой странице сайта поставщика - на странице детализации товара. Сама ссылка для перехода на эту страницу, находится в блоке block.

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

  • Сформировать ссылку на страницу детализации
  • Перейти по этой ссылке и считать с помощью requests.get() новый HTML-код уже этой страницы - страницы детализации
  • Сохранить полученные данные в новом объекте Beautiful Soup
  • Извлечь номер кода с помощью всё того же метода soup.select_one()

# find and open detail page
        url_detail = block.select_one('.item-title')
        # <a class="item-title" href="/catalog/profilnye_truby_ugolki/truba_profilnaya_40kh20_2_mm_3m/" itemprop="url" title="Труба профильная 40х20 2 мм 3м">

        url_detail = url_detail['href']
        # '/catalog/profilnye_truby_ugolki/truba_profilnaya_40kh20_2_mm_3m/'

        url_detail = URL_SCRAPING_DOMAIN + url_detail

        html_detail = requests.get(url_detail).text
        soup = BeautifulSoup(html_detail, 'html.parser')
        code_block = soup.select_one('.catalog-detail-property')
        code = code_block.select_one('b').text
        data['code'] = code

        data_list.append(data)

        print(data)

Если мы всё сделать правильно, то в итоге получим список словарей с данными по каждому блоку.

Добавляем обработку ошибок

Успех скрейпинга сайтов зависит от целого ряда параметров и обстоятельств. И большинство из них не зависит от нашего кода в Django, а именно:

  • Доступность (или НЕдоступность сайта-поставщика)
  • Изменение вёрстки страницы
  • Проблемы интернет-соединения
  • и так далее…

Разумеется, избежать всех этих ошибок не в наших силах. Но в наших силах перехватить эти ошибки, чтобы понять и, по-возможности, минимизировать или обойти причину их возникновения.

Удобнее всего это сделать с помощью класса Исключений (Exceptions). Прежде всего создаём класс исключений скрейпинга ScrapingError. И далее Наследует от него 3 новый пользовательских класса:

class ScrapingError(Exception):
    pass


class ScrapingTimeoutError(ScrapingError):
    pass


class ScrapingHTTPError(ScrapingError):
    pass


class ScrapingOtherError(ScrapingError):
    pass

И далее вносим соответствующие изменения в код:

try:
        resp = requests.get(URL_SCRAPING, timeout=10.0)
    except requests.exceptions.Timeout:
        raise ScrapingTimeoutError("request timed out")
    except Exception as e:
        raise ScrapingOtherError(f'{e}')

    if resp.status_code != 200:
        raise ScrapingHTTPError(f"HTTP {resp.status_code}: {resp.text}")

Сохраняем полученные данные в БД

Итак, данные по каждому блоку получены и преобразованы в удобный формат (список словарей). Теперь остаётся добавить эти данные в базу данных. Иными словами, заменить строчку кода print(data) на код, заполняющий таблицу Product:

for item in data_list:
        if not Product.objects.filter(code=item['code']).exists():
            Product.objects.create(
                name=item['name'],
                code=item['code'],
                price=item['price'],
                unit=item['unit'],
                image_url=item['image_url'],
            )

    return data_list

Как видно, продукт добавляется только в том случае, если его ещё нет в БД. Поиск ведётся по номеру кода продукта (значение поля code ).

Несмотря на то, что в самой функции scraping.py данные уже записываются в БД, мы всё равно возвращаем список data_list. Просто на всякий случай).

Однако, если сейчас мы попробуем воспроизвести этот скрипт, то получим ошибку:

"/home/su/Projects/django-apps/Projects/drop-ship-store/venv/lib/python3.8/site-packages/django/conf/__init__.py", line 67, in _setup
    raise ImproperlyConfigured(
django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.

Process finished with exit code 1

Всё дело в том, что теперь скрипт обращается к БД, а значит, требуется получить установки Django. Можно запустить этот код для проверки в management/commands (более подробно об этом можно найти здесь: https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/) Но мы поступим иначе: сразу же добавим страницу запуска и проверим работу функции scraping() уже там.

Переносим управление скрейпингом на страницу сайта

Алгоритм добавления новой страницы остаётся прежним:

  • Придумывает url, который будет её вызывать (shop/fill-database/)
  • Добавляем конфигуратор urls.py в приложение shop
  • Устанавливаем в urls.py связь url и view (path('fill-database/', views.fill_database, name='fill_database'),
  • Переносим (копируем) файл из шаблона в проект
  • Создаём view в модуле shop/views.py
  • Проверяем результат!

Если после успешного выполнения всех этих пунктов мы зайдём в админку, то увидим что, после запуска скрипта, таблица Product наполнилась новыми значениями:

Теперь всё готово для последнего шага: добавления непосредственно страниц интернет магазина и кода, который будет ими управлять. Но этим мы займёмся уже на следующем седьмом и последнем занятии.

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






Переход на следующий этап проекта

Читать дальше >>

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

5. Создание функционала базы данных методом TDD (Test Driven Development) или пример Разработки через тестирование

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

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

Зачем программисты пишут тесты?

Большинство программистов не любит писать тесты.

Оно и понятно - мало кто любит делать двойную работу: сначала написать и отладить код, а потом ещё столько же времени (если не больше!) затратить на написание тестов.

Хорошо, если проект будет поддерживаться и дальше. Тогда время, затраченное на написание тестов, гарантированно окупится: убедиться в том, что создавая новое мы не сломали старое, можно будет за секунды.

Однако, если проект будет закрыт, то к потерям времени на написание основного кода добавятся ещё и затраты на тесты.

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

Тем не менее, есть подход в программировании, который позволяет если не выиграть, то, как минимум, почти не проиграть при написании тестов. И называется это подход "Test Driven Development" (TDD) ("Разработка через тестирование"). Идея очень проста: мы СНАЧАЛА пишем тесты, и только потом пишем сам код, который должен пройти эти тесты.

И в чём же здесь плюс? А плюс в том, что в этом случае полностью исключается затраты времени на проверку кода. Ведь даже самый первый прогон свеже-написанного кода будет делаться уже "не руками", а тестами!

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

Собственно, именно поэтому в Django достаточно хорошо разработан инструментарий для тестирования кода. И именно поэтому уже в первом учебном примере на сайте этого фреймворка целый урок из восьми отводится на рассмотрение примера реализации этого принципа: "Разработка через тестирование" или "Test Driven Development" (TDD).

В нашем учебном мини-курсе мы рассмотрим подход разработки через тестирование при создании базы данных, поскольку основная вычислительная нагрузка или основная бизнес-логика будет находится именно в модуле shop/models.py, отвечающем за взаимодействие между таблицами базы данных.

Итак, наши задачи на этом этапе:

  • Сформулировать основные требования к структуре БД
  • Составить дизайн БД (нарисовать таблицы и связи между ними)
  • Сформулировать требования к бизнес-логике БД
  • Перенести эти требования в код тестов
  • Написать код, который пройдёт эти тесты

Формулируем требования к структуре БД

Для упрощения задачи (всё-таки, это мини-курс!) оговорим три допущения:

  • Наши товары имеют плоскую структуру (нет разбивки на категории)
  • У нас дропшиппинг, а следовательно нет склада и не может быть складских остатков (предполагается бесконечный остаток товара по каждой позиции)
  • Наша корзина - это заказ со статусом cart. Просто после нажатия кнопки “Перейти к оплате” этот статус меняется на waiting_for_payment

Таким образом, структура данных будет состоять из 4-х таблиц:

  • Таблицы Заказа Order
  • Таблицы Элементов заказа OrderItems (или элементов, из которых состоит каждый заказ)
  • Таблицы Товаров Product (ассортимента)
  • И таблицы Оплат Payment

Ещё нам потребуется таблица Клиентов User, но на наше счастье она уже присутствует в Django по умолчанию, хотя и в самой простой базовой версии. Однако, на этапе учебного проекта этого будет более, чем достаточно.

Создаём структуру БД

Всем вышеперечисленным требованиям вполне удовлетворяет эта блок-схема:

Поэтому в модуле shop/models.py создаём следующую структуру:

class Product(models.Model):
    name = models.CharField(max_length=255, verbose_name='product_name')
    code = models.CharField(max_length=255, verbose_name='product_code')
    price = models.DecimalField(max_digits=20, decimal_places=2)
    unit = models.CharField(max_length=255, blank=True, null=True)
    image_url = models.URLField(blank=True, null=True)
    note = models.TextField(blank=True, null=True)

class Payment(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    amount = models.DecimalField(max_digits=20, decimal_places=2, blank=True, null=True)
    time = models.DateTimeField(auto_now_add=True)
    comment = models.TextField(blank=True, null=True)

class Order(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    # items = models.ManyToManyField(OrderItem, related_name='orders')
    status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=STATUS_CART)
    amount = models.DecimalField(max_digits=20, decimal_places=2, blank=True, null=True)
    creation_time = models.DateTimeField(auto_now_add=True)
    payment = models.ForeignKey(Payment, on_delete=models.PROTECT, blank=True, null=True)
    comment = models.TextField(blank=True, null=True)

class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.PROTECT)
    quantity = models.PositiveIntegerField(default=1)
    price = models.DecimalField(max_digits=20, decimal_places=2)
    discount = models.DecimalField(max_digits=20, decimal_places=2, default=0)

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

class Meta:
        ordering = ['pk']

    def __str__(self):
        return f'{self.user} --- {self.time.ctime()} --- {self.amount}'

Завершающим шагом создания БД будет создание миграция и их применение:

python manage.py makemigrations
python manage.py migrate

И последний штрих - добавить управление и отображение БД в админку:

from django.contrib import admin

from shop.models import Product, Payment, OrderItem, Order

admin.site.register(Product)
admin.site.register(Payment)
admin.site.register(OrderItem)
admin.site.register(Order)

Формулируем требования к бизнес-логике БД

  1. При выборе товара пользователем, товар должен в выбранном количестве добавляться в корзину Cart, которая должна создаваться автоматически (если, конечно, она уже не была создана к этому моменту)

  2. Корзина, не перешедшая в статус Заказа в течении 7 дней, должна автоматически удаляться при первом же вызове метода get_cart(user)

  3. При каждом новом добавлении (удалении, изменении) количества (или цены) товара, общая сумма заказа должна автоматически пересчитываться

  1. После завершения набора/изменения Корзины и перехода к оплате, Корзина должна менять статус (если она не пустая!) и становиться Заказом, ожидающим оплаты (waiting_for_payment)

  2. Необходим метод get_unpaid_orders(user), который позволит получить общую сумму неоплаченных заказов (status=waiting_for_payment) по указанному пользователю

  3. Необходим метод get_balance(user), который позволит получить баланс по счёту указанного пользователя

  1. Смена статуса на waiting_for_payment автоматически запускает проверку баланса текущего пользователя. Если сумма баланса >= сумме заказа, то Заказ изменяет свой статус на оплаченный. При этом параллельно создаётся оплата, равная (минус) сумме заказа (что сразу же после оплаты уменьшает баланс счёта клиента на сумму заказа)

  2. Внесение оплаты автоматически запускает механизм проверки всех неоплаченных заказов, начиная с самого старого. Если внесённой суммы оплаты достаточно для оплаты нескольких заказов, ожидающих оплаты, то все эти заказы изменяют свой статус на оплаченный. При этом параллельно формируются оплаты, равные (минус)сумме каждого заказа (что сразу же после оплаты уменьшает баланс счёта клиента на сумму заказов)

Как видим, список проверок достаточно внушительный. Даже однократное ручное выполнение всех перечисленных тестов - “та ещё работёнка”, которая может занять минут 15, не меньше.

Но мы-то хорошо знаем из собственного опыта, что редкий код начинает работать без ошибок с первого раза. Следовательно, ручное тестирование заняло бы часы нашего времени. А каждый новый рефакторинг кода - это снова часы на проверку. Не говоря уже о том, что при ручном тестирование можно что-то забыть проверить, или забыть удалить тестовые значения из БД.

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

Пишем тесты (переносим требования к БД в код тестов)

При написании тестов необходимо помнить следующее:

  • Для тестов на уровне приложения уже существуют готовый модуль tests.py
  • В основе Django-тестов лежат unittest’ы
  • По умолчанию, перед запуском КАЖДОГО теста создается собственная тестовая БД, которая после завершения теста немедленно удаляется.
  • Из этого следует, что “свежесозданная” тестовая БД просто физически не может иметь тестовых значений. Значит, эти тестовые значения БД необходимо создавать каждый раз при каждом новом тесте.

  • Удобнее всего для этих целей использовать фикстуры - специальные файлы (обычно json-формата), содержащие в себе значения для тестовой БД. “Свежесозданная” тестовая БД автоматически загружает в себя эти значения перед каждым тестом.
  • Если вы ещё не знакомы с понятием фикстур, то самый простой способ с ними познакомиться - вручную заполнить БД и затем выгрузить эти значения по команде: python manage.py dumpdata -o mydata.json.gz (подробнее об этом здесь: https://docs.djangoproject.com/en/4.0/ref/django-admin/#dumpdata)
  • Фикстуры, которые содержат данные для заполнения тестовой БД, необходимо добавить в приложение в каталог shop/fixtures/data.json
  • В качесте атрибута класса тестового случая необходимо указать

fixtures = [
        "shop/fixtures/data.json"
    ]

  • Необходимо помнить, что в тестовой БД должен быть создан суперпользователь. Поэтому, если суперпользователя нет в фикстурах, то его необходимо создать в методе setUp(self)

Пример содержимого файла тестов shop/tests.py представлен ниже:

from django.test import TestCase,
from shop.models import *

class TestDataBase(TestCase):
    fixtures = [
        "shop/fixtures/data.json"
    ]

    def setUp(self):
        self.user = User.objects.get(username='root')

    def test_user_exists(self):
        users = User.objects.all()
        users_number = users.count()
        user = users.first()
        self.assertEqual(users_number, 1)
        self.assertEqual(user.username, 'root')
        self.assertTrue(user.is_superuser)

    def test_user_check_password(self):
        self.assertTrue(self.user.check_password('123'))

Пишем код, который пройдёт эти тесты

Пример фрагмента кода, используемого в лекции, представлен ниже:

def change_status_after_payment(payment: Payment):
    """
    Calling the method after creating a payment and before saving it.
    First need to find total amount from all payments (previous and current) for the user.
    If total amount >= the order amount, we change status and create negative payment.
    """
    user = payment.user
    while True:
        order = Order.objects.filter(status=Order.STATUS_WAITING_FOR_PAYMENT, user=user) \
            .order_by('creation_time') \
            .first()
        if not order:
            break

        total_payments_amount = get_balance(payment.user)
        if order.amount > total_payments_amount:
            break

        order.payment = payment
        order.status = Order.STATUS_PAID
        order.save()
        Payment.objects.create(user=user, amount=-order.amount)


@receiver(post_save, sender=Payment)
def on_payment_save(sender, instance, **kwargs):
    if instance.amount > 0:
        # pay_for_waiting_orders(instance)
        change_status_after_payment(instance)


@receiver(post_save, sender=Order)
def on_order_save(sender, instance, **kwargs):
    if Order.objects.filter(status='2_waiting_for_payment'):
        pay_for_waiting_orders(instance)

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






Переход на следующий этап проекта

Читать дальше >>

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

4. Регистрации и авторизации пользователей на сайте

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

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

Регистрация и авторизация в Django

Немного теории

Как уже неоднократно упоминалось, формат этого курса предполагает в первую очередь практику и ещё раз практику. И всё же, без определения понятий сессия, cookies, GET- и POST-запросы будет очень сложно понять суть того, что мы будет делать дальше. Поэтому, несколько слов об этом, всё же, стоит сказать.

Понятие сессии, cookies

Как сайте “узнает”, что на него заходят “свои”, а не “чужие”?

Давайте откроем в браузере вкладку Storage → Cookies и попробуем зарегистрироваться на сайте. Мы увидим, что в момент регистрации появится новый ключ session_id, который будет существовать ровно до тех пор, пока мы авторизованы на этом сайте. Если мы откроем базу данных этого сайта, то в таблице в таблице django_session увидим тот же самый сессионный ключ.

Однако, если мы разлогинимся, то этот session_id исчезнет и из базы, и из cookies браузера. Дело в том, что после авторизации пользователя на сайте, сервер создаёт в своей БД особую цифровую метку, копию который он отправляет браузеру пользователя, и браузер сохраняет эту цифровую метку в своей памяти. Это и есть ключ сессии, который сохраняется в памяти браузера. Эти данные, сохранённые в памяти браузера и называются cookies.

(На всякий случай: браузер для SQLite, упомянутый в видео, можно установить с помощью этих команд:
$ sudo add-apt-repository -y ppa:linuxgndu/sqlitebrowser
$ sudo apt-get update
$ sudo apt-get install sqlitebrowser
)

До тех пор, пока пользователь залогинен на сайте, session_id в cookies его браузера совпадает с session_id, сохраненными в БД. Что даёт возможность пользователю посещать любые страницы сайте, к которым у него есть доступ.

Для этого браузер при каждом новом запросе на сервер отправляет туда session_id, и пока этот код совпадает с кодом в БД сервера, пользователь остаётся авторизованным.

Если же пользователь разлогинится, то сервер удалит его session_id из своей базы данных. И на те страницы сайта, где требуется авторизованный вход, этот пользователь войти уже на сможет.

Если же пользователь залогинится снова, то он опять сможет посещать любые страницы сайта. Но это уже будет совсем другая сессия с другим session_id.

Модель User

Все таблицы в базе данных (БД) Django описываются с помощью моделей. Это специальные Python классы, наследуемые от класса Model.

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

Более подробно мы разберём примеры моделей на следующем 5-м уроке. Ну, а пока просто примем к сведению, что модель пользователя User, имеющая определённый набор базовых полей и методов, уже существуют по умолчанию. Она всегда создаётся в Django во время первой миграции. В частности, там уже есть поля username, email, password, is_staff (является ли пользователь сотрудником) и is_superuser.

POST и GET запросы

Новые незнакомые аббревиатуры обычно внушают страх. Чтобы избавиться от, как минимум, половины этого страха, сразу же отметим, что все запросы, о которых мы говорили до сих пор - это GET запросы. Правда, ведь, совсем не страшно?!! Тогда идём дальше!

Итак, при обращению к серверу возможны разные типы запросов. Вообще, HTTP протокол имеет их достаточно много, но в данном курсе мы будем использовать только два:

  • GET-запрос - когда нам нужно просто отобразить какую-либо страницу
  • и POST-запрос - когда нам требуется передать на сервер данные для их сохранения в БД

Для передачи GET-запроса достаточно указать обычный адрес url. А вот передача POST запроса ОБЯЗАТЕЛЬНО предполагает наличие формы и csrf-токена.

Формы, csrf-token

Форма в HTML - это специальная конструкция, которая заключена в тег <form></form>. Внутри этого могут также содержаться поля ввода с одиночным тегом <input>, которые обычно идут в паре с тегом <label></label>, который поясняет, что именно надо вводить в поле input.

Форма также содержит кнопку (поле ввода) с type="submit", нажатие на которую как раз и является командой отправки данных на сервер.

Примерно ток выглядит форма в коде HTML:

<form method="post" action="/your-url/">
    <label for="username">User name: </label>
    <input id="username" type="text" name="username" value="{{ username }}">
    <input type="submit" value="OK">
</form>

Передача url в другие приложения. Страницы register.html и login.html

В предыдущем уроке мы уже научились передавать управление из конфигуратора main/urls.py во view. Однако, хорошей практикой является и другой вариант: промежуточная передача url из главного конфигуратора в конфигуратор другого приложения.

Для этого в main/urls.py мы добавим строку, благодаря который все запросы авторизации, начинающиеся с ‘auth/’ будут передаваться на обработку в другой конфигуратор - authentication/urls.py:

from django.contrib import admin
from django.urls import path, include

from company import views as views_company

urlpatterns = [
    path('', views_company.home_page, name='index'),
    path('about/', admin.site.urls),
    path('admin/', admin.site.urls),
    path('auth/', include('authentication.urls')),
]

Следовательно, в authentication/urls.py появятся запросы, соответствующие авторизации, регистрации и разлогиниванию:

from django.urls import path

from authentication import views

urlpatterns = [
    path('login/', views.login_user, name='login'),
    path('register/', views.register, name='register'),
    path('logout/', views.logout_user, name='logout'),
]

Теперь, для начала сделаем простые view, которые будут запускаться по этим трём запросам в приложении authentication:

def login_user(request):
    return render(request, ‘auth/login.html’)

def register(request):
    return render(request, ‘auth/register.html’)

def logout_user(request):
    pass

C разлогиниванием пока ничего не понятно - страницы под него не предусмотрено, поэтому вместо кода просто напишем pass.

Ну а, вообщето, шаблон здесь и не нужен. А нужна специальная функция разлогинивания logout(request)

Тем не менее, если мы ограничимся во view только этой функцией, то получим ошибку: The view authentication.views.logout_user didn't return an HttpResponse object. It returned None instead.

Поэтому в конце view нам придётся вернуть переход на какую-либо страницу. Например на главную. Сделать это можно с помощью функции redirect('index'), где 'index' - имя адреса в authentication/urls.

Создание формы в модуле form.py

Очевидно, что теперь конструкция для всех view в приложении авторизации будет не такой простой, как была для index view. Поскольку, теперь у нас появилась новая задача: прочитать данные с HTML-страницы, передать их на сервер и далее в таблицу User нашей базы данных . И, как уже говорилось выше, всё, что передёются в базу данных, должно передаваться только с помощью формы и POST-запроса.

Для создания форм в Django предусмотрен специальный модуль forms.py, в котором можно предварительно создать объект "форма ввода" и подробно описать его свойства и характеристики. Форма обычно связана с конкретной таблицей в базе данных. Поэтому, создавая форму следует:

  • Указать к какой таблице эта форма относится
  • Выбрать список полей (можно выбрать все поля и указать конкретный список)
  • Определить список обязательных к заполнению полей
  • Описать прочие свойства полей
  • и так далее

Поскольку мы используем уже готовую Django’вскую таблицу User с довольно-таки объёмным списком стандартных полей, то в нашей форме обязательно следует указать какие именно поля будут использованы:

from django import forms
from django.contrib.auth.models import User


class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput())

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

В следующем уроке мы увидим, что создание формы внешне очень похоже на создание таблицы базы данных.

Получение данных с html-страницы

Таким образом, в наше вью login_user необходимо будет добавить следующие опции:

  • Передать форму LoginForm на страницу login.html
  • Обеспечить безопасный ввод данных в эту форму
  • Добавить на страницу кнопку управления, с помощью который пользователь сможет передать данные на сервер
  • Обеспечить приём данных на сервере, их валидацию
  • И, если это необходимо, добавить полученные данные в соответствующую таблицу базы данных

Передать любые данные на страницу можно с помощью дополнительно созданного словаря context. В нашем варианте по ключу login_form мы добавим экземпляр класса только что созданной формы LoginForm():

def login_user(request):
    context = {'login_form': LoginForm()}
    return render(request, 'auth/login.html', context)

Для передачи данных на html-страницу этого вполне достаточно, но для получения данных со страницы потребуется добавить обработку данных по POST-запросу.

def login_user(request):
    context = {'login_form': LoginForm()}

    if request.method == 'POST':
        login_form = LoginForm(request.POST)
        if login_form.is_valid():
            username = login_form.cleaned_data['username']
            password = login_form.cleaned_data['password']
            user = authenticate(username=username, password=password)
            if user:
                login(request, user)
                return redirect('index')

    return render(request, 'auth/login.html', context)

Одновременно с этим на html-странице необходимо будет добавить:

  • Теги формы <form></form>
  • В теге формы следует обязательно указать метод POST и url, по которому этот POST-запрос должен быть обработан
  • Добавить тег <input> с параметром name, равным имени полей формы
  • И, наконец, добавить тег <input> (или <button>) с параметром type=’”submit” по клику на который пользователь сможет передать данные на сервер.

То есть фрагмент формы в HTML-коде будет выглядеть примерно так:

<form method="post" action="{% url 'login' %}">
    {% csrf_token %}
    <label class="form-label" for="id_username">Login</label>
    <input type="text" class="form-input" id="id_username"
                               name="username" placeholder="Login" required>
    <label class="form-label" for="id_password">Password</label>
    <input type="password" class="form-input" id="id_password"
                               name="password" placeholder="Password" required>
    <button type="submit" class="request-quote-btn">Log In</button>
</form>

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

Обработка ошибок ввода данных

Заполнение формы может быть с ошибками - это совершенно нормально. Не нормально, если сервер “молча” не примет эти данные и не “объяснит” пользователю почему.

Есть, как минимум, два способа.

1.) Самое простое решение - просто сообщить пользователю, что введённые данные не верны. Для этого в словарь context просто добавляется новое поле attention. Кроме того, правильно будет вернуть форму именно с теми данными, которые не прошли.

if user:
    login(request, user)
    return redirect('index')
context = {
    'login_form': LoginForm(request.POST),
    'attention': f'The user with name {username} is not registered in the system!'}

2.) Другой, более изящный и аккуратный способ: сообщить пользователю какие именно ошибки возникли в процессе валидации. И здесь потребуется изменения непосредственно в самой форме:

def clean(self):
    cleaned_data = super().clean()
    username = cleaned_data.get('username')
    password = cleaned_data.get('password')

    try:
        self.user = User.objects.get(username=username)
    except User.DoesNotExist:
        raise forms.ValidationError(f'User with username: [{username}] does not exist!')

    if not self.user.check_password(password):
        raise forms.ValidationError('Could not log in using these email and password')

    return cleaned_data

Теперь эти ошибки будут добавлены к экземпляру класса формы LoginForm(). Правда, чтобы их прочитать на странице авторизации, также потребуются незначительные изменения HTML кода:

{% for error in login_form.non_field_errors %}
    <div class="alert alert-danger">{{ error }}</div>
{% endfor %}

На этом миссию создания страницы авторизации можно считать выполненной!

Создание view регистрации с помощью generic views

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

Наше знакомство с темой generic views мы начнём пока только с одного варианта - класса TemplateView. (Далее в этом курсе мы рассмотрим ещё несколько видов generic views).

В качестве примера перепишем наше register вью, выполненное на основе функции, в RegisterView с использованием класса TemplateView:

from django.views.generic import TemplateView

class RegisterView(TemplateView):
    template_name = 'auth/register.html'

Для обработки GET и POST запросов у этого класса есть методы get и post:

def get(self, request):
        user_form = RegisterForm()
        context = {'user_form': user_form}
        return render(request, 'auth/register.html', context)

    def post(self, request):
        user_form = RegisterForm(request.POST)
        if user_form.is_valid():
            user = user_form.save()
            user.set_password(user.password)
            user.save()
            login(request, user)
            return redirect('index')

        context = {'user_form': user_form}
        return render(request, 'auth/register.html', context)

На способ создания записи нового пользователя в таблице User стоит обратить особое внимание, так как этот способ мы будем применять и далее:

  • Сначала создаётся объект user - экземпляр модели (класса) User (но иногда это можно сделать с помощью метода save() класса ModelForm).
  • Далее (если необходимо) этот объект может изменяться / дополняться (в примере выше мы сохраняем строковое значение пароля в формате хэш-суммы).
  • Но непосредственно в базу данных новый объект добавляется с помощью другого метода save() - метода Django-модели.

О форме RegisterForm, которая используется здесь в методе post, следует сказать отдельно.

Формы класса ModelForm

Предыдущая форма LoginForm была создана на основе класса Form - довольно-таки простого варианта, внешне очень похожего на класс моделей Model.

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

Пример новой формы:

class RegisterForm(forms.ModelForm):
    class Meta:
        model = User
        fields = ('username', 'email', 'password')

Встраивание формы в HTML-код

В предыдущем примере с формой авторизации LoginForm мы предприняли попытку встроить готовую форму в HTML-страницу с помощью тега {{ login_form }}. Однако, быстро отказались от этой затеи - уж больно сильно отличались стили стандартной Django-формы и стили вёрстки нашей страницы login.html.

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

  • Вывод непосредственно самого поля ввода.
  • Связанные с этим полем метки (заголовка) поля.
  • Ошибки ввода, относящиеся к данному полю.

Пример HTML-кода, реализующие описанное выше:

{% for field in user_form %}
<div class="row">
    <div class="col-12 col-lg-4 col-md-4 offset-md-4">
        <div class="quote-form-wrapper">
            <span style="color: red">{{ field.errors }}</span>
            {{ field.label_tag}}
            {{ field }}
        </div>
    </div>
</div>
{% endfor %}

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






Переход на следующий этап проекта

Читать дальше >>

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

3. Встраивание HTML-шаблона в проект Django. Создание базовой и главной страницы. Перенос статики

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

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

Добавление в проект главной страницы html-шаблона и настройка статики

Итак, наш нестандартный чек-лист (в виде доработанного HTML-шаблона) создан и теперь самое время приступить к "зачёркиванию" его пунктов. Начнём с главной страницы.

Прежде всего необходимо убедиться, что в корень проекта были добавлены каталоги tamplates для html-файлов и static для файлов статики (css-, js-и image-файлов). И что в файл настроек проекта settings.py были добавлены пути этих каталогов относительно базовой директории.

Собственно, убедиться в этом совсем несложно: актуальная схема-дерево нашего проекта представлена на слайде ниже:

├── authentication
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── db.sqlite3
├── main
│   ├── asgi.py
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── manage.py
├── requirements.txt
├── shop
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── static
├── templates
└── venv

Теперь копируем статику: копируем в папку проекта static всё содержимое папки asserts HTML-шаблона. Далее копируем главную страницу, пока так, как она есть (т.е. пока без выделения общей части в базовую страницу base-page.html). Просто скопируем файл шаблона index.html в папку templates нашего проекта.

Далее, нам нужно помнить, что для отображения страницы в Django-проекта необходимо, как минимум, 2 действия:

  • Указать url данной страницы, по которому в проекте она будет вызываться.
  • И создать специальную функцию (или класс), называемую view (или представление), которая будет запускаться при переходе по указанной ссылке и отображать нашу страницу.

  • Сейчас в нашем проекте помимо главной папки main присутствуют ещё две папки приложений: папка shop и папка authentication. Во всех этих трёх папках уже есть, или может быть добавлена пара файлов urls.py - views.py. Но только одна из них: пара из главной папки main может обеспечить url, в котором не будет дополнительной добавки фрагментов адреса url в виде /auth/ или /shop/. Иными словами ссылки (urls) из папки main будут вести на "чистый" адрес имени домена.

    Разумеется, совершенно логично, когда главная страница отображается по адресу имени домена. Так что можно сказать, что с местом размещения пары urls.py - views.py мы определились, осталось только добавить в папку main пока что недостающий там файл views.py. А далее сделать в этих файлах соответствующие записи:

    main/urls.py

    from django.contrib import admin
    from django.urls import path
    from shop import views
    
    urlpatterns = [
        path('', views.index, name='index'),
        path('admin/', admin.site.urls),
    ]

    Далее, создадим простейшее view, в задачи которого будет входить только рендеринг (создание) указанного шаблоне. Для его создания можно использовать готовый файл view одного из наших приложений, либо создать новый файл views.py в папке main.

    main/views.py

    from django.shortcuts import render
    
    
    def index(request):
        return render(request, 'index.html')

    Пробуем запустить, и видим, что главная страница отобразилаcь, но стили на ней полностью отсутствуют. Оно и понятно: и ссылки на стили теперь должны быть прописаны совершенно на другие пути (раньше файлы статики были в папке asserts, а теперь она находится в папке static), и сам способ написания этих ссылок тоже должен стать совершенно другим.

    • Во-первых, теперь каждая HTML-страница должна начинаться с команды загрузки статики {% load static %}.
    • А во-вторых, во всех ссылках путь ко всем файлам статики должен теперь выглядеть не так

    <link rel="stylesheet" href="../construct/assets/css/style.css">

    а вот так:

    <link rel="stylesheet" href="{% static '/css/style.css' %}">

    Обновляем страницу, и видим, что картина изменилась, но кроме надписи Loading... ничего не появляется. Значит надо обновить все ссылки на файлы статики главной страницы. И особенно, на файлы js в конце страницы...

    Ну вот, теперь что-то видно. Определённые стили появились, хотя далеко не всё. Чтобы появились все, надо последовательно "пройтись" по всем ссылкам и аккуратно и внимательно их изменить. Как видим, без минимального знания HTML на бекенде не обойтись.

    Выделяем общую часть всех страниц проекта в базовую страницу

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

    Прежде всего необходимо обратить внимание на тот факт, что все страницы нашего HTML-шаблона имеют одну общую часть, в которую входят header (включая главное меню) и footer. Эти элементы присутствуют абсолютно на всех страницах. Поэтому нет никакого смысла сначала дублировать вставку одного и того же кода, а потом ещё и мучиться с изменением ссылок на файлы статики. Будет куда разумнее, если мы выделим эту общую часть в отдельную базовую страницу base-page.html, и оставим на этой базовой странице места вставок для уникального контента из нужных нам страницы проекта.

    Теперь каждая HTML-страница, кроме базовой, будет иметь точно такую же структура, как и страница index.html:

    {% extends 'base-page.html' %}
    {% load static %}
    
    {% block title %}
        < Page Name >
    {% endblock title %}
    
    {% block container %}
        < Current Page Content >
    {% endblock container %}

    То есть теперь при загрузке страницы index.html сначала идёт загрузка базовой страницы base-page.html (extends 'base-page.html'), затем загружается статика для этой страницы (load static), и далее в указанные места вставок добавляются текущие значение блоков < Page Name > и < Current Page Content >.

    Аналогично будут оформлены и все прочие страницы. Например, для следующей страницы about-us.html нужно проделать те же самые операции:

    • Добавить новую строку с новым url в файл main/urls.py;
    • Добавить новою view в файл main/views.py;
    • И добавить в папку templates страницу about-us.html.

    main/urls.py

    from django.contrib import admin
    from django.urls import path
    from shop import views
    
    urlpatterns = [
        path('', views.index, name='index'),
        path('about/', views.about, name='about'),
        path('admin/', admin.site.urls),
    ]

    Далее, создадим простейшее view, в задачи которого будет входить только рендеринг (создание, построение) указанного шаблона. Для его создания можно использовать готовый файл view одного из наших приложений, либо создать новый файл views.py в папке main.

    main/views.py

    from django.shortcuts import render
    
    
    def about(request):
        return render(request, 'about-us.html')

    И последний третий шаг: добавляем в папку templates страницу about-us.html. Изменённый HTML-код этой страницы, по сути, теперь будете состоять только из того кода, что раньше находился между header'ом и footer'ом. А структура страницы about-us.html ничем не будет отличаться от страницы index.html.

    index.html

    {% extends 'base-page.html' %}
    {% load static %}
    
    {% block title %}
        < Page Name >
    {% endblock title %}
    
    {% block container %}
        < Current Page Content >
    {% endblock container %}

    Таким образом, после создания базовой страницы, мы можем в шаблонах всех остальных страниц сайта размешать только тот оригинальный HTML-код, которого нет в базовом шаблоне. Разумеется, не забывая при этом давать ссылку на базовый шаблон. И всё! Это невероятно уменьшает дублирование кода и позволяет легко вносить изменения во все страницы сайта сразу изменяя, всего лишь, код на базовой странице.

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






    Переход на следующий этап проекта

    Читать дальше >>

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

    2. Планирование рабочей схемы и функционала проекта. Выбор и доработка HTML-шаблона

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

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

    Планирование

    На прошлом занятии мы подготовили среду разработки, создали в ней новый проект, добавили в этот проект необходимые настройки и даже успели создать два новых приложения, которые будут отвечать за регистрацию/авторизацию и за работу интернет-магазина. Иными словами, мы выполнили начальные типовые операции, которые соответствует подготовительному этапу 99,9(9)% подобных проектов. И теперь самое время составить подробный план наших дальнейших действий.

    Планирование проектов, на самом деле, задача непростая. Для этих целей создана даже специальная научная дисциплина "Управление проектами" (Project management). Разумеется, формат нашего мини-курса не позволит нам слишком уж углубляться в эту. И, тем не менее, пару слов об управлении проектами сказать всё-таки нужно.

    Думаю, ни для кого не открою Америки, если скажу, что выполнить проект можно и без всякого плана. Просто получится дольше и дороже. (Если вообще получится!)

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

    • Что именно мы делаем?
    • Как мы это делаем?
    • Чем (С помощью каких ресурсов выполняется текущий этап?)
    • Когда (К какому сроку?)

    Если по всем пунктам плана есть ответы на эти вопросы - нас можно поздравить: у нас действительно есть план! Ну, а если - нет, значит у нас не план, а всего лишь мечта!

    Но как быть, если мы собираемся сделать то, что ещё никто и никогда не делал? И если ни на один из этих четырёх вопросов нет ни одного ответа?

    Ответ очень простой: нужно выделить это неизвестное направление, эту “Terra incognita” в отдельный план. План исследовательской работы.

    Но как тут быть с последним пунктом, со сроками? Ведь у исследовательской работы крайне сложно определить сроки?

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

    Ну, а дальше всё просто: если мы смогли ответить на все неизвестные вопросы ДО наступления дедлайна, значит переходим ко второму этапу: оформляем результаты исследований в обычный план и начинаем работу. Ну, а если не уложились, значит, похоже, что мы взялись не за своё дело. Поэтому немедленно прекращаем эту затею и начинаем работать над решением другой задачи.

    По счастью, задача, стоящая перед нами в этом мини-курсе, самая что ни есть обычная и тривиальная. Поэтому здесь надо просто аккуратно переписать все этапы нашего плана в чек-лист, и, по мере выполнения этих этапов, постепенно их вычёркивать, двигаясь от строчки к строчке.

    Однако, в нашем мини-курсе мы пойдём ещё дальше, и в качестве такого "чек-листа" возьмём готовый HTML-шаблон, где каждая HTML-страница будет соответствовать этапу нашего плана. Порядок работы будет такой: перенесли страницу из шаблона в проект - считай зачеркнули строчку в чек-листе! И так до самого конца шаблона, пока не "повычёркиваем" из шаблона все HTML-страницы!

    Вероятно тому, кто ещё так не делал, эта идея покажется совершенно абсурдной. Но не спешите делать выводы!

    Где взять HTML-шаблон для проекта?

    Вариантов ответа на этот вопрос, как минимум, три:

    1. Сделать самому,
    2. Заказать у профессионалов,
    3. Воспользоваться услугами магазина готовых шаблонов.

    Все эти варианты имеют свои плюсы и минусы:

    1. Первый вариант потребуют времени, дизайнерских способностей и квалификации верстальщика. Т. е. подойдёт далеко не всем.
    2. Второй вариант предполагает у вас наличие некоторого финансового запаса, и, как ни странно, ОЧЕНЬ ясного понимания того, что именно вы хотите получить в итоге. Здесь важно помнить, что стоимость хорошего фронтенда может представлять собой сумму со многими нулями, и что новичку будет очень непросто разобраться, где заканчивается производственная необходимость и начинается пустая трата денег. Тем более, что хорошие профессионалы умеют не только хорошо делать, но и хорошо убеждать)
    3. Поэтому оптимальным вариантом для всех и, особенно для владельцев стартапа, будет посещение онлайн-магазина готовых шаблонов. Здесь разные варианты можно не только посмотреть, но даже потрогать (в смысле "покликать"). Цены здесь вполне демократичные, и даже встречаются совершенно бесплатные варианты. Например, бесплатный шаблон, который используется в нашем проекте был взят вот в этом интернет магазине: Template Monster. Правда, в настоящее время именно этот вариант там недоступен, но по-прежнему, есть достаточное количество похожих и бесплатных вариантов. Кстати, пожалуйста, обратите внимание, что нам подойдут только HTML5 шаблоны.

    Планируем страницы будущего шаблона

    Итак, выбранный шаблон загружен с сайта Template Monster и распакован в отдельную папку. Если мы откроем в браузере файл index.html, то может показаться, что перед нами уже готовый проект: все (или почти все) кнопки и ссылки работают и ведут на нужные страницы. С одной, правда, существенной оговоркой: все эти красивые страницы наполнены не совсем той информацией, которая нам нужна. Следовательно, именно сейчас нам предстоит определить, какие именно страницы мы оставим, какие удалим, а какие, наоборот, добавим.

    Давайте теперь определимся с набором страниц нашего будущего шаблона. Для нашего проекта потребуются следующие страницы:

    1. Home page - главная страница.
    2. Страница регистрации.
    3. Страница авторизации (входа на сайт)
    4. Страница каталога товаров. Здесь будет добавлено управление выбором товара и помещением его в корзину в нужном количестве
    5. Страница детализации товара - страница более детальной информация по товару
    6. Страница корзины - перечень товаров, выбранных для покупки и оплаты.
    7. Страница управления скрейпингом. Должна быть доступна только для персонала компании и НЕдоступна для клиентов

    Очевидно, что все эти страницы (кроме страницы детализации) должны иметь ссылки в главном меню. И желательно также чтобы меню регистрации/авторизации было бы отдельным от главного.

    Планирование функционала сайта

    База данных будет максимально простой (всё-таки курс у нас мини, а не макси).

    Таблица товаров будет “плоской”, т.е. без категорий/подкатегорий. Мера измерения товара будет “штуки”. Выбранный товар с установленным количеством автоматически попадает в корзину. Если корзины для этого пользователя ещё нет, она создаётся, если уже есть - дополняется.

    Если товары из корзины отправлены на оплату, то корзина обнуляется и появляется Заказа, со статусом “ожидающий оплаты”.

    Для учёта оплат должна быть создана таблица платежей. Если сумма зачисленной оплаты больше или равна сумме заказа, то Заказ меняет статус на “оплаченный”, а в самой таблице платежей формируется “отрицательный” платёж с “минус-суммой”. Таким образом, для определения баланса счёта клиента не надо будет создавать новой таблицы баланса. Кроме того эта схема позволит также решить вопрос с онлайн оплатой: при подключении агрегатора онлайн оплаты, системе нужно будет всего лишь создать платёж, равный сумме полученной оплаты. Этого будет достаточно для автоматического перевода Заказа в категорию оплаченного.

    И предусматриваем работу с платежами по банку:

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

    Всё, коллеги! С планированием покончено и переходим к превращению только что скачанного HTML-шаблона в “чек-лист” нашего проекта.

    Доработка шаблона

    Итак, выбранный шаблон загружен с сайта Template Monster и распакован в отдельную папку. Если мы откроем в браузере файл index.html, то может показаться, что перед нами уже готовый проект: все (или почти все) кнопки и ссылки работают и ведут на нужные страницы. С одной, правда, существенной оговоркой: все эти красивые страницы наполнены не совсем той информацией, которая нам нужна. Следовательно, именно сейчас нам предстоит определить, какие именно страницы мы оставим, какие удалим, а какие, наоборот, добавим.

    1. Конечно же, оставляем домашнюю страницу index.html (HOME).
    2. Можно также оставить страницу about-us.html (ABOUT). Раз мы по легенде строительная компания, значит когда-нибудь придётся давать информацию о себе. В данном проекте ничего на этой странице мы размещать не будет, но оставим как задел на будущее.

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

    1. Страница регистраци register.html (Register);
    2. И страница авторизации login.html (Log In).

    Кстати, для ссылок на страницы регистрации было использовано готовое меню выбора языков на главной страницы. Ссылки на следующие страницы были размещены в главном меню:

    1. Страница отображения всего списка товаров интернет-магазина shop.html (SHOP). Там же будет добавлена возможноcть выбора товара в нужном количестве.
    2. Страница детализации выбранного товара shop-details.html (пункта меню на главной страницы у неё нет - открывается по кнопке "Detail", расположенной рядом с каждым товаром.
    3. Страница корзины cart.html (CART)
    4. И страница запуска скрипта скрейпинга (парсинга) товаров со стороннего сайта fill-products.html (FILL-DATABASE)

    Ну, вот, кажется и всё: все основные моменты плана учтены, и все нужные страницы добавлены.

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

    Резюме: если по мере реализации проекта возникла необходимость скорректировать план - корректируйте не задумываясь!

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






    Переход на следующий этап проекта

    Читать дальше >>

    Дропшиппинг интернет-магазин на Django

    1. Вступление. Для кого этот курс? Подготовка среды разработки

    Вступление

    Эта статья - представление нового курса Drop Shipping Store on Django, где за 7 занятий из бесплатных материалов, находящихся в открытом доступу, будет создан полноценный интернет-магазин, который может торговать чем угодно на условиях дропшиппинга. Если кто ещё не в курсе, то дропшиппинг - это способ организации бизнеса, при котором продавец размещает на своих виртуальных витринах товары, которых у него нет, но которые он в любой момент может получить у своего постоянного поставщика. Следовательно, задача владельца такого интернет-магазина: получить заказ и оплату от клиента, и затем передать поставщику информацию о заказе и полученную оплату за вычетом своей комиссии.

    Всё, что потребуется для создания этого интернет-магазина: программы, необходимый учебные материалы и даже видео-уроки, находятся в открытом доступе. Вам лишь потребуется внимательно и аккуратно выполнять всё, что будет написано и показано в материалах к этому курсу.

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

    Как уже упоминалось выше, все видео, подробно рассказывающие (а главное, показывающие!) процесс разработки, находятся в открытом доступе. Поэтому, внимательно следую рекомендациям на экране вы в итоге получаете реальный рабочий код всего проекта. Однако, для тех, кого сразу же интересует конечный результат, есть вариант получения доступа к архивной копии проекта после каждого урока. Этот доступ можно получить через небольшой донат вот по этой ссылке (потребуется регистрация на сайте).

    Ну, а сам сайт, который получится в итоге, можно посмотреть на видео в конце этой статьи.

    Для кого предназначен этот курс?

    Для всех, кто интересуется веб разработкой, вне зависимости от уровня знаний и опыта!

    1. Те, кто далёк от программирования, и давно мечтают о своём собственном интернет-магазине, смогут познакомиться с основными этапами создания веб-проекта, получить представление о возможностях фреймворка Django и понять тот объём работ, который необходим для реализации этих возможностей. Это позволит, во-первых, точнее и детальнее сформулировать задачи по своему собственному проекту, а, во-вторых, конкретизировать требования к исполнителю для реализации этих самых задач. Что существенно сократит время обсуждения этих задач с исполнителем, а значит и общие затраты по проекту.

    2. Для тех, кто относит себя к категории знаком с основами языка Python это будет отличная возможность попробовать свои силы в создании собственного проекта. Собственно, если вы пока ещё не знаете Python, то от этой категории вас отделяет все лишь два курса: Python для начинающих и Основы языка Python. Пройдя их, вы сможете самостоятельно скорректировать этот проект под многие свои задачи и сохранить результат в своём собственном репозитории. Ну, а то, что вы пока не сможете реализовать самостоятельно, можно будет заказать стороннему исполнителю (в том числе и нам!). В любом случае, доработать часть проекта - это уже совсем не то, что делать проект "с нуля"!

    3. Ну, а если вы знакомы с Python OOP, уже успели попробовать свои силы на сайте Django и создали там своё первое приложение Polls, то этот курс подойдёт вам как нельзя лучше. Благодаря ему вы сможете самостоятельно реализовать в своём приложении большую часть задач. Ну, а исполнение той незначительной часть проекта, которую вы пока не сможете сделать сами, всегда можно заказать специалисту. В том числе и нам :-).

    Подготовка среды разработки

    Мы будем работать на операционной системе Linux, а в качестве IDE будем использовать Community (бесплатную) версию PyCharm. Выбор Linux неслучаен: именно на этой ОС работают все серверы, где используется фреймворк Django. Поэтому если Вы работаете на Windows, то лучшим решением будет дополнительно установить виртуальную машину с ОС Linux. О том, как это сделать подробно рассказано и показано в этих видео:

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

    После проведения всех вышеперечисленных дополнительных приготовлений, мы наконец-то можем приступить непосредственно к созданию самого проекта в следующем порядке:

    • создадим виртуальное окружение для нашего проекта;
    • установим на него пакет Django;
    • создадим сам проект;
    • изменим файл settings.py, отвечающий за настройки нашего проекта;
    • добавим в проект два новый приложения:
      • приложение, отвечающее за регистрацию и авторизацию пользователей (authentication);
      • и приложение непрсредственно самого интернет-магазина (shop);
    • проведём первую миграцию;
    • создадим суперпользователя;
    • добавим в .gitignore .idea для PyCharm;
    • и "запушим" всё сделанное в репозиторий.

    После выполнения всех пунктов этого чек-листа попробуем запустить проект. Ура - наш проект уже работает! И всё, что нам остаётся, - это "слегка" доработать его функционал!

    Зайдём в админку под логином и паролем только что созданного суперпользователя. Как видим, здесь уже есть модели пользователей и групп пользователей.

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

    Результаты всей этой проделанной подготовительной работы текущего этапа, вы можете найти по ссылке внизу видео, которая приведёт вас на курс Drop Shipping Store on Django. И, если курс у Вас оплачен, то Вы можете скачать архивную копию этого этапа (т. е. того, что сделано к настоящему моменту) (потребуется регистрация на сайте).

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






    Переход на следующий этап проекта

    Читать дальше >>

    Связанные выпадающие списки в Админке Django

    В чём идея?

    Для тех, кто ещё не знаком со связанными (зависимыми) выпадающими списками и пока не успел оценить по достоинству тот невероятный набор удобств, который они дают при работе с электронными таблицами, рекомендую познакомиться с этой статьёй. (Там же есть ссылка на видео)

    Те же, кому очевидные достоинства связанных выпадающих списков рекламировать без надобности, рано или поздно "упираются" в естественные, и увы, неизбежные ограничения электронных таблиц:

    • требование достаточно высокой квалификации к персоналу,
    • отсутствие "защиты от дурака",
    • сложность в организации защиты и распределении прав доступа,
    • сложность в организации автообмена данными с другими источниками информации (вебсайты, базы данных)
    • и т.д. и т.п.

    Особенно это актуально для владельцев малого бизнеса, хорошо знакомых с симптомами "болезни роста": то, что невероятно помогало ещё вчера стало мешать и тормозить сегодня.

    С этой целью и была предпринята попытка совместить достоинства электронных таблиц и базы данных. И местом для подобного "полигона" была выбрана административная панель популярного фреймоворка Django, написанного на языке программирования Python.

    Почему именно Django?

    Главным аргументом при этом выборе стала административная панель этого фреймворка. Админка Django имеет табличную структуру, так что внешнее сходство с электронным таблицами уже налицо. Поэтому доработки до уровня соответствия заданным параметрам здесь требуются самые минимальные.

    Ну, а такие приятные "мелочи", как невероятная популярность этого фреймворка, его надёжность и производительность просто укрепили в этом выборе.

    Подготовка проекта и настройка среды разработки

    Python - дитя Unix. Поэтому, выбор unix-подобной операционной системы (Linux или macOS) будет совершенно логичным решением. Тем более, что переходить на компьютер с другой OC совсем не обязательно: можно просто установить на своём компьютере с любой операционной системой виртуальную машину и установить Linux уже на неё. А также установить PyCharm - удобную и бесплатную среду разработки.

    О том, как это сделать подробно рассказано и показано в этих видео:

    После успешной установки всего вышеперечисленного можно переходить к созданию проекта. Для этого надо создать каталог с названием проекта django-dropdown-admin, войти в этот каталог и запустить процесс создания виртуального окружения и тут же его запустить:

    $ virtualenv venv - p python3
    
    $ source venv/bin/activate

    Виртуальное окружение запущено, о чём говорит имя папки в скобках, появившееся в начале командной строки: (venv).

    Далее устанавливаем Django:

    (venv) $ pip install django

    Создаём проект (обратите внимание на точку в конце!):

    (venv) $ django-admin startproject main .

    И приложение dropdown:

    (venv) $ django-admin startapp dropdown

    В результате должно получиться что-то вроде этого:

    .
    ├── dropdown
    │   ├── admin.py
    │   ├── apps.py
    │   ├── __init__.py
    │   ├── migrations
    │   │   └── __init__.py
    │   ├── models.py
    │   ├── tests.py
    │   └── views.py
    ├── main
    │   ├── asgi.py
    │   ├── __init__.py
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    ├── manage.py
    └── venv

    С этого момента все дальнейшие действия удобнее будет выполнять в IDE PyCharm. (см видео в конце этой статьи)

    1. Изменяем файл settings.py

    Прежде всего внесём изменения в настройки - файл main/settings.py:

    1.) Добавим вновь созданное приложение в INSTALLED_APPS

    INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        # === my apps =======
        'dropdown',
    ]

    2.) Отменим сложную валидацию паролей пользователей для режима отладки

    # Password validation
    # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
    
    if not DEBUG:
        AUTH_PASSWORD_VALIDATORS = [
            {
                'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
            },
            {
                'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
            },
            {
                'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
            },
            {
                'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
            },
        ]

    3.) И укажем путь для файлов статики (потребуется для изменения js админки)

    # Static files (CSS, JavaScript, Images)
    # https://docs.djangoproject.com/en/4.0/howto/static-files/
    
    STATIC_URL = 'static/'
    
    STATICFILES_DIRS = [
        BASE_DIR / "static",
    ]

    Чтобы логически завершить последнее действие, сразу же добавим в директорию проекта новый каталог для файлов статики: static

    2. Создаём базу данных (БД)

    Как известно в Django используется ORM (Object–relational mapping), поэтому для создания БД необходимо всего-навсего описать её структуру в файлеmodels.py и затем провести миграции, которые, по описанию этой структуры, создадут необходимые таблицы базы данных и установят между полями этих таблиц все необходимые связи.

    Содержимое файла dropdown/models.py:

    from django.db import models
    
    
    class Category(models.Model):
        name_cat = models.CharField(max_length=250)
    
        def __str__(self):
            return self.name_cat
    
    
    class Subcategory(models.Model):
        cat = models.ForeignKey(Category, on_delete=models.CASCADE,
                                verbose_name='category')
        name_subcat = models.CharField(max_length=250)
    
        def __str__(self):
            return self.name_subcat
    
    
    class Good(models.Model):
        subcat = models.ForeignKey(Subcategory, on_delete=models.CASCADE,
                                   verbose_name='subcategory')
        name_good = models.CharField(max_length=250)
        price = models.DecimalField(max_digits=6, decimal_places=2)
    
        def __str__(self):
            return self.name_good
    
    
    class OrderItem(models.Model):
        order = models.ForeignKey("dropdown.Order", on_delete=models.CASCADE, verbose_name="order")
        cat = models.ForeignKey(Category, on_delete=models.CASCADE, verbose_name='cat')
        subcat = models.ForeignKey(Subcategory, on_delete=models.CASCADE, verbose_name='subcat')
        name_good = models.ForeignKey(Good, on_delete=models.CASCADE, verbose_name='good')
        quantity = models.PositiveIntegerField(default=0)
        amount = models.DecimalField(max_digits=9, decimal_places=2)
    
        def __str__(self):
            return f'{self.name_good} + {self.quantity}'
    
    
    class Order(models.Model):
        order_id = models.PositiveIntegerField(unique=True)
        order_date = models.DateTimeField(auto_now=True)
        total_quantity = models.PositiveIntegerField(default=0)
        total_amount = models.DecimalField(max_digits=9, decimal_places=2)
    
        def __str__(self):
            return str(self.order_id)
    
    
    class AllowedCombination(models.Model):
        cat = models.ForeignKey(Category, on_delete=models.CASCADE)
        subcat = models.ForeignKey(Subcategory, on_delete=models.CASCADE)
        good = models.ForeignKey(Good, on_delete=models.CASCADE)
    
        def __str__(self):
            return f'{self.cat} {self.subcat} {self.good}'
    
        class Meta:
            ordering = ['pk']

    Теперь, когда структура БД описана, необходимо её воплотить в самой БД, для чего мы сначала в окне терминала первой командой создаём миграции (python manage.py makemigrations):

    (venv) $ python manage.py makemigrations 
    
    Migrations for 'dropdown':
      dropdown/migrations/0001_initial.py
        - Create model Category
        - Create model Good
        - Create model Order
        - Create model Subcategory
        - Create model OrderItem
        - Add field subcat to good
        - Create model AllowedCombination

    А затем, следующей командой (python manage.py migrate), применяем все эти миграции к нашей базе данных:

    (venv) $ python manage.py migrate
    
    Operations to perform:
      Apply all migrations: admin, auth, contenttypes, dropdown, sessions
    Running migrations:
      Applying contenttypes.0001_initial... OK
      Applying auth.0001_initial... OK
      Applying admin.0001_initial... OK
      Applying admin.0002_logentry_remove_auto_add... OK
      Applying admin.0003_logentry_add_action_flag_choices... OK
      Applying contenttypes.0002_remove_content_type_name... OK
      Applying auth.0002_alter_permission_name_max_length... OK
      Applying auth.0003_alter_user_email_max_length... OK
      Applying auth.0004_alter_user_username_opts... OK
      Applying auth.0005_alter_user_last_login_null... OK
      Applying auth.0006_require_contenttypes_0002... OK
      Applying auth.0007_alter_validators_add_error_messages... OK
      Applying auth.0008_alter_user_username_max_length... OK
      Applying auth.0009_alter_user_last_name_max_length... OK
      Applying auth.0010_alter_group_name_max_length... OK
      Applying auth.0011_update_proxy_permissions... OK
      Applying auth.0012_alter_user_first_name_max_length... OK
      Applying dropdown.0001_initial... OK
      Applying sessions.0001_initial... OK

    3. Предварительная настройка Админки (административной панели фреймворка Django)

    В самом простейшем варианте, мы можем работать с нашей базой данных прямо из Админки. Кстати, это тот редкий случай, когда "простейшее" совсем не означает "худшее". Как раз наоборот - стандартная админка Django уже содержит в себе невероятно удобные и мощные инструменты для добавления, изменения и удаления данных из БД.

    И всё, что для этого нужно - это все лишь сделать минимальное описание структуры Админки в файле dropdown/admin.py с помощью следующего кода:

    from django.contrib import admin
    
    from .models import (AllowedCombination, Category, Good, Order, OrderItem,
                         Subcategory)
    
    admin.site.register(Category)
    admin.site.register(Subcategory)
    admin.site.register(Good)
    
    
    class OrderItemInline(admin.TabularInline):
        model = OrderItem
        extra = 0
    
    
    class OrderAdmin(admin.ModelAdmin):
        inlines = [OrderItemInline]
    
    
    admin.site.register(Order, OrderAdmin)
    
    
    class OrderItemAdmin(admin.ModelAdmin):
        pass
    
    
    admin.site.register(OrderItem, OrderItemAdmin)
    
    
    class AllowedCombinationAdmin(admin.ModelAdmin):
        list_display = ['cat', 'subcat', 'good', ]
    
    
    admin.site.register(AllowedCombination, AllowedCombinationAdmin)

    Для входа в Админку осталось сделать последний шаг - создать Суперпользователя.

    Поэтому снова входим в окно терминала и сначала вводим команду создания Суперпользователя (python manage.py createsuperuser), а затем последовательно отвечаем на вопросы системы:

    (venv) $ python manage.py createsuperuser
    Username (leave blank to use 'su'): root
    Email address: root@root.com
    Password: 
    Password (again): 
    Superuser created successfully.

    4. Наполняем БД новыми данными

    Теперь самое время проверить, что у нас получилось. Запускаем сайт с помощью команды python manage.py runserver

    (venv) $ python manage.py runserver
    
    Watching for file changes with StatReloader
    Performing system checks...
    
    System check identified no issues (0 silenced).
    April 06, 2022 - 17:33:16
    Django version 4.0.3, using settings 'main.settings'
    Starting development server at http://127.0.0.1:8000/
    Quit the server with CONTROL-C.

    и переходим в админку по url: http://127.0.0.1:8000/admin/

    И логинимся как Суперпользователь, используя login и password, которые ввели при созданиии пользователя superuser.

    В нашем варианты будет три связанных выпадающих списка:

    • Категории (Categories)
    • Подкатегории (Subcategories)
    • и непосредственно Товары (Goods)

    Необходимо сначала заполнить их, а потом заполнить таблицу Допустимых Комбинаций (AllowedCombination) на основании которой наш скрипт и будет предлагать значения в выпадающих списках. Ну, что-то вроде этого:

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

    5. Делаем списки связанными

    Всё, что было до этого - самый, что на есть, обычный и стандартный Django. Который умеет работать со списками, но пока ещё не умеет делать их связанными, то есть предлагать значения последующего списка с оглядкой на выбор предыдущего.

    Чтобы научить Django новому функционалу, надо добавить немного "магии", а именно:

    • Добавить новую форму (файл dropdown/forms.py
    • Добавить новый js-скрипт (файл static/js/restricted-model-choice-field.js
    • Внести изменения в уже существующий файл dropdown/admin.py

    Добавляем файл dropdown/forms.py:

    import json
    from typing import Any
    
    from django import forms
    from django.forms import Media, widgets
    
    from .models import Good, OrderItem, Subcategory
    
    
    class RestrictedSelect(widgets.Select):
        @property
        def media(self):
            media = super().media
            media += Media(js=["js/restricted-model-choice-field.js"])
            return media
    
    
    class BoundRestrictedModelChoiceField(forms.BoundField):
        def get_restrictions(self):
            restrictions = {}
    
            restrict_on_form_field = self.form.fields[self.field.restrict_on_form_field]
            # Можно оптимизировать
            for restricting_object in restrict_on_form_field.queryset:
                allowed_objects = self.field.queryset.filter(**{self.field.restrict_on_relation: restricting_object})
                for obj in allowed_objects:
                    restrictions.setdefault(obj.id, set()).add(restricting_object.id)
    
            return restrictions
    
        def build_widget_attrs(self, attrs, widget=None):
            attrs = super().build_widget_attrs(attrs, widget)
    
            restrictions = self.get_restrictions()
            restrictions = {k: [str(v) for v in vs] for k, vs in restrictions.items()}
            attrs["data-restrictions"] = json.dumps(restrictions)
    
            bound_restrict_on_form_field = self.form[self.field.restrict_on_form_field]
            attrs["data-restricted-on"] = bound_restrict_on_form_field.html_name
    
            return attrs
    
    
    class RestrictedModelChoiceField(forms.ModelChoiceField):
        widget = RestrictedSelect
    
        def __init__(self, *args, restrict_on_form_field: str = None, restrict_on_relation: str = None, **kwargs):
            super().__init__(*args, **kwargs)
    
            if not restrict_on_form_field:
                raise ValueError("restrict_on_form_field is required")
            self.restrict_on_form_field = restrict_on_form_field
    
            if not restrict_on_relation:
                raise ValueError("restrict_on_relation is required")
            self.restrict_on_relation = restrict_on_relation
    
        def get_bound_field(self, form, field_name):
            return BoundRestrictedModelChoiceField(form, self, field_name)
    
    
    class OrderItemForm(forms.ModelForm):
        class Meta:
            model = OrderItem
            fields = "__all__"
    
        subcat = RestrictedModelChoiceField(
            Subcategory.objects.all(),
            restrict_on_form_field="cat",
            restrict_on_relation="allowedcombination__cat",
        )
        name_good = RestrictedModelChoiceField(
            Good.objects.all(),
            restrict_on_form_field="subcat",
            restrict_on_relation="allowedcombination__subcat",
        )

    Добавляем файл статики static/js/restricted-model-choice-field.js:

    (function(){
      function throwError(baseElement, message) {
        console.error(message, baseElement);
        throw new Error(message);
      }
    
      function reset(baseElement) {
        baseElement.value = "";
      }
    
      function getRestrictedFields() {
        return Array.prototype.slice.apply(document.querySelectorAll("[data-restrictions]"));
      }
    
      function getFieldsRestrictedOn(baseElement) {
        let elements = getRestrictedFields();
        return elements.filter(e => getRestrictedOnField(e) === baseElement);
      }
    
      function isFormsetTemplate(baseElement) {
        return baseElement.name.indexOf("__prefix__") >= 0;
      }
    
      function getRestrictedOnField(baseElement) {
        if (isFormsetTemplate(baseElement)) {
          return null;
        }
    
        let fieldName = baseElement.getAttribute("data-restricted-on");
        if (!fieldName) {
          throwError(baseElement, "data-restricted-on is undefined");
        }
    
        if (fieldName.indexOf("__prefix__") >= 0) {
          fieldName = cleanDynamicFormSetName(baseElement, fieldName);
        }
    
        let form = baseElement.closest("form");
        if (!form) {
          throwError(baseElement, "The field is not inside a form");
        }
    
        let fields = form.querySelectorAll(`[name=${fieldName}]`);
        if (fields.length == 0) {
          throwError(baseElement, `Could not find field ${fieldName}`);
        }
    
        if (fields.length > 1) {
          console.warn(`Found multiple fields ${fieldName}`);
        }
        return fields[0];
      }
    
      function cleanDynamicFormSetName(baseElement, fieldName) {
        let prefixIx = fieldName.indexOf("__prefix__");
        let selfPrefix = baseElement.name.slice(prefixIx);
        let prefixMatch = selfPrefix.match(/\d+/);
        if (!prefixMatch) {
          throwError(baseElement, `Cannot detect dynamic formset prefix: ${baseElement.name}`);
        }
    
        return fieldName.replace("__prefix__", prefixMatch[0]);
      }
    
      function getRestrictions(baseElement) {
        let restrictionsJson = baseElement.getAttribute("data-restrictions");
        if (!restrictionsJson) {
          throwError(baseElement, "data-restrictions is undefined");
        }
    
        return JSON.parse(restrictionsJson);
      }
    
      function updateOptionList(baseElement) {
        if (isFormsetTemplate(baseElement)) {
          return;
        }
    
        let refField = getRestrictedOnField(baseElement);
        if (!refField) {
          throwError(baseElement, "Could not find refField");
        }
    
        let restrictions = getRestrictions(baseElement);
    
        let options = Array.prototype.slice.apply(baseElement.querySelectorAll("option"));
        options.forEach(option => {
          if (!option.value) {
            option.hidden = false;
            return;
          }
    
          let allowedOnValues = restrictions[option.value] || [];
          option.hidden = allowedOnValues.indexOf(refField.value) < 0;
        });
      }
    
      function clearOptionList(baseElement) {
        let options = Array.prototype.slice.apply(baseElement.querySelectorAll("option"));
        options.forEach(option => {
          option.hidden = true;
        });
      }
    
      document.addEventListener("change", event => {
        let element = event.target;
        getFieldsRestrictedOn(element).forEach(baseElement => {
          reset(baseElement);
          updateOptionList(baseElement);
          baseElement.dispatchEvent(new Event("change", {bubbles: true}));
        });
      });
    
      document.addEventListener("DOMContentLoaded", () => {
        getRestrictedFields().forEach(baseElement => {
          if (isFormsetTemplate(baseElement)) {
            clearOptionList(baseElement);
          } else {
            updateOptionList(baseElement);
          }
        });
      })
    })();

    И вносим изменения в уже существующий файл dropdown/admin.py:

    from django.contrib import admin
    
    from .forms import OrderItemForm
    from .models import (AllowedCombination, Category, Good, Order, OrderItem,
                         Subcategory)
    
    admin.site.register(Category)
    admin.site.register(Subcategory)
    admin.site.register(Good)
    
    
    class OrderItemInline(admin.TabularInline):
        model = OrderItem
        form = OrderItemForm
        extra = 0
    
    
    class OrderAdmin(admin.ModelAdmin):
        inlines = [OrderItemInline]
    
    
    admin.site.register(Order, OrderAdmin)
    
    
    class OrderItemAdmin(admin.ModelAdmin):
        form = OrderItemForm
    
    
    admin.site.register(OrderItem, OrderItemAdmin)
    
    
    class AllowedCombinationAdmin(admin.ModelAdmin):
        list_display = ['cat', 'subcat', 'good', ]
    
    
    admin.site.register(AllowedCombination, AllowedCombinationAdmin)

    Ву а-ля! Теперь в нашей Админке при заполнении таблицы Заказа (Order) мы можем использовать Связанные Выпадающие списки:

    А общий список файлов нашего проекта выглядит теперь следующим образом:

    .
    ├── db.sqlite3
    ├── dropdown
    │   ├── admin.py
    │   ├── apps.py
    │   ├── forms.py
    │   ├── __init__.py
    │   ├── migrations
    │   │   ├── 0001_initial.py
    │   │   └── __init__.py
    │   ├── models.py
    │   ├── __pycache__
    │   │   ├── admin.cpython-38.pyc
    │   │   ├── apps.cpython-38.pyc
    │   │   ├── __init__.cpython-38.pyc
    │   │   └── models.cpython-38.pyc
    │   ├── tests.py
    │   └── views.py
    ├── main
    │   ├── asgi.py
    │   ├── __init__.py
    │   ├── __pycache__
    │   │   ├── __init__.cpython-38.pyc
    │   │   ├── settings.cpython-38.pyc
    │   │   ├── urls.cpython-38.pyc
    │   │   └── wsgi.cpython-38.pyc
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    ├── manage.py
    ├── static
    │   └── js
    │       └── restricted-model-choice-field.js
    └── venv

    Более подробно о создание этого проекта рассказано (и, самое главное, показано!) в этом видео:

    Читать дальше >>

    Список тэгов

        Apps Script      Arrays Java Script      asynchronous code      asyncio      coroutine      Django      Dropdown List      Drop Shipping      Exceptions      GitHub      Google API      Google Apps Script      Google Docs      Google Drive      Google Sheets      multiprocessing      Parsing      Python      regex      Scraping      ssh      Test Driven Development (TDD)      threading      website monitoring      zip