Блог

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

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

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

    Как установить SSH-ключ на GitHub

    Для того, чтобы получить доступ к репозиторию на ГитХабе по ssh, прежде всего необходимо создать сам аккаунт.

    Далее, после авторизации необходимо перейти в настройки аккаунта (кликнуть на значок своего аватара в правом верхнему углу экрана и выбрать пункт settings) --> SSH and GPG keys), и далее кликнуть кнопку New SSH key (тоже справа вверху).

    Теперь в эти два открывшихся поля (Title и Key) необходимо вставить название ключа (любое) и код публичной части самого ключа (скопировать содержимое ключа).

    Где их взять? Прежде всего есть смысл проверить, не сгенерировали ли вы свой ключ раньше и просто забыли об этом. Для этого надо перейти в папку Home и поискать там ключи в скрытой папке .ssh (предварительно в Навигаторе надо добавить возможность просмотра скрытых файлов). Другой способ обнаружить эту папку - через окно терминала по команде:

    $ ls -al ~/.ssh

    Если ключи есть, то мы увидим, как минимум, 4 строки текста

    drwx------ 2 su su 4096 сеп 24 23:24 .
    drwxr-xr-x 23 su su 4096 сеп 24 23:24 ..
    -rw------- 1 su su 419 сеп 24 23:24 id_ed25519
    -rw-r--r-- 1 su su 104 сеп 24 23:24 id_ed25519.pub

    Ну, а если нет - то только две верхних. В этом случае, ключи нам придётся сгенерировать самостоятельно:

    $ ssh-keygen -t ed25519 -C "my_email@example.com"

    Система сообщит о начале процесса генерации и уточнит, под каким именем следует сохранить файл с ключом:

    Generating public/private ed25519 key pair.
    Enter file in which to save the key (/home/su/.ssh/id_ed25519):

    Просто соглашаемся (Enter).

    Далее система сообщит о создании директории .ssh и предложит ввести фразу для пароля (вводить не обязательно):

    Created directory '/home/su/.ssh'.
    Enter passphrase (empty for no passphrase):

    Если ввели пароль, то следующим шагом потребуется ввести подтверждение, если - нет, то просто нажмите Enter.

    И в результате пароль будет сформирован и на экран будет выведено что-то вроде этого:

    Enter same passphrase again:
    Your identification has been saved in /home/su/.ssh/id_ed25519
    Your public key has been saved in /home/su/.ssh/id_ed25519.pub
    The key fingerprint is:
    SHA256:FOkr66ZIZN5eTZ1omlY77s1GkgZtWa+A3NNDwEKCzcE my_email@example.com
    The key's randomart image is:
    +--[ED25519 256]--+
    | =oo..oo |
    | . E.. o.o |
    | . *.= . |
    | +.Ooo.. |
    | o oS=oo |
    | + . .B=.o |
    | o . =++o |
    | . o oo. +. |
    | . o+..o.o |
    +----[SHA256]-----+

    Теперь необходимо перейти в папку .ssh и скопировать публичную часть ключа, которая находится в файле с расширением: .pud (id_ed25519.pub).

    Содержимое именно этого файла нам надо скопировать и вставить в поле Key экрана настройки ключей на ГитХабе. Далее необходимо придумать имя для этого ключа (на случай если ключей будет несколько) и ввести это имя в поле Title. После заполнения обоих полей и нажатия кнопки Add SSH key, добавление ключа можно считать завершённым. И теперь неплохо бы было убедиться, что всё работает.

    Для этого вводим в окне терминала команду тестового подключения к ГитХаб:

    ssh -T git@github.com

    Если ssh-ключи находятс в правильном месте, а публичный ключ корректно скопирован в настройки ГитХаб, то будет что-то вроде этого:

    "Hi UserName! 
    You've successfully authenticated, but GitHub does not provide shell access."

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

    Нажимает "плюс" слева от аватара в верхнем правом углу ГитХаба, между "колокольчиком" и аватаром --> New repository

    Далее, в открывшейся странице вводим имя проекта под Repository name, пишем несколько слов о проекте в разделе Description, отмечаем "добавить" для Add a README file и Add .gitignore, и в вариантах ".gitignore" выбираем Python. И в завершении всего нажимаем Create repository.

    Проект создан, и теперь мы копируем его в папку на локальном компьютере, для чего в переключателе HTTPS / SSH выбираем последнее и копируем ссылку на наш только что созданный проект в репозитории ГитХаба.

    Но перед тем, как скопировать, необходимо убедиться, установлен ли на нашим компьютере пакет git. Это можно сделать командой:

    $ git --version

    Если же окажется, что git не установлен, то применяем следующие команды;

    $ sudo apt-get update
    $ sudo apt-get install git

    Теперь, непосредственно в той папке, где мы хотим разместить этот проект, открываем окно терминала (правая кнопка мыши --> "Open in Terminal"), и далее, в самом терминале вводим:

    $ git clone git@github.com:it4each/my-project.git

    При первом клонировании скорее всего мы получим что-то вроде этого:

    Cloning into 'my-project'...
    The authenticity of host 'github.com (140.82.121.4)' can't be established.
    RSA key fingerprint is SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8.
    Are you sure you want to continue connecting (yes/no/[fingerprint])?

    Отвечаем yes и проект успешно добавляется в указанную папку на нашем компьютере. Кроме того, адрес ГитХаба добавился в список известных хостов (см. далее).

    Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
    Warning: Permanently added 'github.com,140.82.121.4' (RSA) to the list of known hosts.
    remote: Enumerating objects: 4, done.
    remote: Counting objects: 100% (4/4), done.
    remote: Compressing objects: 100% (3/3), done.
    remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
    Receiving objects: 100% (4/4), done.

    И если мы теперь ещё раз исследуем содержимое папку ключей, то увидим в ней новый файл (known_hosts) с со списком известных хостов, куда добавлен и хост ГитХаба:

    $ ls -al ~/.ssh drwx------ 2 su su 4096 сеп 25 10:49 .
    drwxr-xr-x 23 su su 4096 сеп 24 23:39 ..
    -rw------- 1 su su 411 сеп 24 23:43 id_ed25519
    -rw-r--r-- 1 su su 102 сеп 24 23:43 id_ed25519.pub
    -rw-r--r-- 1 su su 884 сеп 25 10:49 known_hosts

    Теперь, для работы проекта нам необходимо создать виртуальное окружение. Для этого необходимо наличие пакетов pip3 и virtualenv. Если эти пакеты ещё не установлены, используем команды:

    # install pip3
    $ sudo apt-get install python3-pip  
    
    # install virtual environment
    $ sudo pip3 install virtualenv

    Для создания виртуального окружения необходимо перейти в папку проекта и затем запустить команду установки:

    $ cd my-project
    
    $ virtualenv venv -p python3

    Виртуальное окружение создано. Теперь его необходимо запустить:

    user@user:~/Projects/django-app/my-project$ source venv/bin/activate
    
    (venv) user@user:~/Projects/django-app/my-project$

    Доказательство того, что виртуальное окружение запущено - появление в ответе системы папки venv в круглых скобках - (venv).

    Устанавливаем первый пакет - пакет Django. И сразу же после его установки создаём новый проект (чтобы создать его внутри папки проекта, в конец команды добавляем точку):

    $ pip3 install Django
    
    $ django-admin startproject main .

    Все установленные пакеты запоминаем в файле requirements.txt:

    $ pip3 freeze > requirements.txt

    Теперь в нашей папке уже достаточно файлов для того, чтобы сделать первый коммит и сохранить проделанную работу в репозитории ГитХаба. Для этого сначала добавляем все созданные файлы командой add --all, затем создаём коммит commit и отправляем этот коммит в репозиторий командой push:

    $ git add --all
    
    $ git commit -am "first commit"
    
    $ git push

    Если всё прошло без ошибок, то в репозиторий должны добавиться следующие файлы:

    [main 0a792bb] first commit
    7 files changed, 200 insertions(+)
    create mode 100644 main/__init__.py
    create mode 100644 main/asgi.py
    create mode 100644 main/settings.py
    create mode 100644 main/urls.py
    create mode 100644 main/wsgi.py
    create mode 100755 manage.py
    create mode 100644 requirements.txt

    Обратите внимание, что самая большая по объёму папка нашего проекта - папка виртуального окружения venv в репозиторий не попала, поскольку по умолчанию находится в файле .gitignore - в списке файлов-исключений для копирования в удалённое хранилище. Если помните, добавить этот файл нам предложил ГитХаб при создании проекта my-project.

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

    Дополнительную информацию о добавлении SSH ключа и создании проекта можно получить из этого видео:

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

    Исключения в Python. Операторы вызова исключений: raise и assert

    Это продолжение статьи Исключения в Python. Введение

    Оператор raise

    Бывают ситуации, когда формально ошибки нет, но переменная принимает такое значение, при котором программа может дать результат, очень далёкий от ожидания. И тогда мы можем вызвать ошибку принудительно, с помощью оператора raise:

    try:
        value = int(input())
        if value % 2 != 0:
            raise ValueError('Variable <value> must be even!')
        print('Congratulations! Variable <value> is even!')
    except Exception as e:
        print(f'{e.__class__}: {e}')

    В приведённом примере исключение будет вызвано в том случае, когда целое число не является чётным, то есть в случае выполнения условия if value % 2 != 0:

    Для этого после оператора raise указывается класс исключения (в данном случае это ValueError), к которому будет отнесена соответствующая группа значений переменной value. А далее, в скобках, добавлен комментарий, поясняющий причину вызова исключения. Ниже приводятся результаты выполнения кода для значений 4 и 5:

    >>>
        5
        <class 'ValueError'>: Variable <value> must be even!
    
        4
        'Congratulations! Variable <value> is even!'

    Если появится другая ошибка, например, если с консоли будет введено значение aaa, то программа перейдёт на обработку этого исключения ещё до оператора raise. И, разумеется, выдаст совершенно другой код ошибки (точнее, выдаст другой комментарий ошибки к общему классу исключений ValueError):

    >>>
        aaa
        <class 'ValueError'>: invalid literal for int() with base 10: 'aaa'

    Оператор assert

    Ещё одна возможность принудительно вызвать ошибку, в случае невыполнения указанного условия — с помощью оператора assert:

    try:
        value = int(input())
        assert value % 2 == 0, 'Variable <value> must be even!'
        print('Congratulations! Variable <value> is even!')
    except Exception as e:
        print(f'{e.__class__}: {e}')

    Варианты выполнения кода для различных входных значений:

    >>>
        5
        <class 'AssertionError'>: 'Variable <value> must be even!
    
        4
        'Congratulations! Variable <value> is even!'
    
        aaa
        <class 'ValueError'>: invalid literal for int() with base 10: 'aaa'

    Таким образом, как видим, оба оператора (raise и assert) очень похожи друг на друга.

    Разве что вариант с assert выглядит самую малость предпочтительней, так как:

    1. assert не требует отдельного блока if — условие проверки с этим оператором делается в одну строку;
    2. В случае assert не требуется указывать класс исключения — по умолчанию это всегда AssertionError

    Вернуться в начало, на статью Исключения в Python. Введение

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

    Исключения в Python. Перехват специфических ошибок

    Это продолжение статьи Исключения в Python. Введение

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

    Но прежде чем перейти к примеру кода, который решает этот вопрос, стоит посмотреть, какие вообще существуют классы исключений в Python. Для этого в первую очередь следует обратиться к официальной документации Python: https://docs.python.org/3/library/exceptions.html .

    Можно также получить список всех встроенных исключений с помощью следующей команды в консоли Python:

    dir(locals()['__builtins__'])

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

    1. Ошибка соответствия значения и типа данных (ValueError, TypeError)
    2. Ошибка деления на ноль ZeroDivisionError
    3. Все остальные виды исключений

    while True:
        line = input('Please enter an integer: ')
        if line == 'end':
            break
    
        try:
            result = 100 / int(line)
            print(result)
    
        except (ValueError, TypeError) as e:
            print('Exception for Value or Type Error')
            print(e.__class__, e)
            print('Please enter another number! ')
    
        except ZeroDivisionError as e:
            print('Exception for ZeroDivisionError')
            print(e.__class__, e)
            print('Please enter another number! ')
    
        except Exception as e:
            print('Exception for other error')
            print(e.__class__, e)
            print('Please enter another number! ')

    Продолжить в статье Исключения в Python. Операторы вызова исключений: raise и assert

    Вернуться в начало, на статью Исключения в Python. Введение

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

    Список тэгов

        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