Skip to content

Instantly share code, notes, and snippets.

@stasyao
Last active February 24, 2024 07:23
Show Gist options
  • Star 49 You must be signed in to star a gist
  • Fork 56 You must be signed in to fork a gist
  • Save stasyao/99376eb0cf0ad3599f9737c421b5210e to your computer and use it in GitHub Desktop.
Save stasyao/99376eb0cf0ad3599f9737c421b5210e to your computer and use it in GitHub Desktop.
Учебное пособие по основам Django

Работаем с джанго-формами. Часть 1

Работаем с джанго-формами. Часть 2

Работаем с джанго-формами. Часть 3

Работаем с джанго-формами. Часть 4

Работаем с джанго-формами. Часть 5

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

Легенда такая. Вы — начинающий бэкендер, которого подключили к проекту создания сайта для заказа космических путешествий.
Часть бэка уже написана, есть фронт главной страницы. Как раз на этой стадии подключаетесь вы.

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

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

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

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

Репозиторий проекта

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

Поехали!

Работаем с джанго-формами. Часть 1

Какова общая логика сайта?

Пока все очень скромно:

  • клиент открывает главную страницу
  • оттуда переходит на страницу с формой заказа. Там он должен выбрать:
    • корабль, на котором хочет полететь
    • дату полета (не раньше чем через месяц после текущей даты)
    • выбрать валюту платежа (пока доступны только доллары США и российские рубли)
  • после выбора всех указанных опций он кликает на кнопку "Оформить". Важная деталь: если все места на выбранный корабль и выбранную дату исчерпаны, должно появляться информация об этом с просьбой перезаполнить форму.
  • если всё прошло штатно, то клиенту должна открыться следующая страница для завершения оформления заказа:
    • должна отображаться стоимость полета, исходя из ранее введененных данных.
      Сейчас стоимость определена в долларах, поэтому если клиент выбрал в качестве валюты рубли, надо конвертировать стоимость в рубли и показать ее по курсу на сегодня (разумеется, с сайта ЦБ).
      Но в базу все равно должно сохраниться значение в долларах.
    • введет свои имя и фамилию
    • адрес электронной почты
    • нажимает на кнопку "Поехали!", после чего заказ сохраняется в базе данных.

Что есть сейчас?

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

С чего начинаем?

Нужна вот такая форма для получения данных image

Необходимые для формы данные нужно получать из БД (кроме валюты платежа).

Шаг 1. Создать страницу, на которой будет располагатся форма выбора корабля, даты полета и валюты платежа

В конце концов, мы же где-то должны показать эту форму :)

Что нужно:

  • решить, куда положить файл с новой страницей
  • создать html-файл с минимально необходимой разметкой
  • на странице сделать заготовку для формы

1.1. Решить, куда положить шаблон с новой страницей

Джанго ищет шаблоны страниц не рандомно, а во вполне конкретных местах.
Что это за места оговоривают в настройках проекта — settings.py, константа TEMPLATES.

Смотрю, что там:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [ BASE_DIR / 'templates' ],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Собственно, интересуют два ключа: APP_DIRS и DIRS.
'APP_DIRS': True. Ага, значит, шаблоны можно хранить в папкаx templates внутри приложений проекта.

'DIRS': [ BASE_DIR / 'templates' ]. Так, ещё можно поместить шаблоны страницу в папку templates внутри BASE_DIR. Поищем в настройках, что за путь у BASE_DIR (внимание: это дефолтный путь в последних версиях джанго, поэтому понимание, как это расшифровывается, пригодится везде):

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

pathlib — это стандартный (т.е. не требующий pip install ...) Python-модуль для работы с файловой системой, не важно unix или win.
Если нужно с помощью питона порыться среди своих папок и файлов, что-то создать или удалить, задать пути к тем или иным файлам, то тут либо pathlib, либо более старый (но по-прежнему актуальный) вариант с os.path (os — ещё одна стандартная Python-библиотека).

Итак, что значит Path(__file__).resolve().parent.parent __file__ — это текущий файл, в нашем случае settings.py
Path(__file__).resolve() — построить путь к текущему файлу. В моем случае это C:\Desktop\form_webinar\config\settings.py

Чтобы более нагляднее было

form_webinar
   |
   config
   |     ├── __init__.py
   |     ├── asgi.py
   |     ├── settings.py
   |     ├── urls.py
   |     └── wsgi.py
   └──media
   |   ├── ...
   └──spaceflights
   |   ├── ...
   └──manage.py

.parent.parent — буквально означает "из текущей директории (=директории, которая является родительской для settings.py) нужно подняться в следующую директорию и взять ее путь"

Таки образом, путем для BASE_DIR будет C:\Desktop\form_webinar\, т.е. та папка, в которой у нас лежит весь проект, та папка, из которой мы вызываем manage.py.

А BASE_DIR / 'templates' — это не что иное, как C:\Desktop\form_webinar\templates.

Итог. Я могу разместить шаблон страницы с формой заказа, как в папке templates внутри папок с приложениям проекта (а тут оно всего одно, назвали его spaceflights).
А могу разместить в общей папке templates в корне проекта, джанго туда тоже заглянет.

Положу в общую папку, проект маленький, да и главную страницу туда же положили. Назову новый шаблон ship.html.

1.2. Cоздать html-файл с минимально необходимой разметкой

Итак, файл создан.
image

Теперь нужно описать минимум разметки — теги html, head, body.
В VS Сode достаточно набрать в html-файле восклицательный знак (Emmet Abbreviation), появится предложение воспользоваться аббревиатурой и приняв это предложение появится необходимая разметка и даже чуть больше

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    
</body>
</html>

Всё наше внимание на теге body, то, что в нем, пользователь увидит на странице в браузере.

1.3. На странице сделать заготовку для формы

Скелет любой html-формы — это тег form.

Создадим его внутри body.

<body>
  <form>
  </form>
</body>

Теперь нужно ответить на три вопроса:

  1. После сабмита (проще говоря, нажатия кнопки типа "Отправить"), какой контроллер ("вьюха", "вью-функция") будет приводится в действие (работать с данными из формы и т.д.)?
  2. Какой url обслуживает этот контроллер (потому что задействовать мы его можем не иначе, как через обращение по урлу)?
  3. Каким http-методом — GET или POST — будут отправляться данные из формы?

Ответы на первые два вопроса нам нужны для описания атрибута actions в теге form. Атрибут actions, конечно, можно опустить, но тогда будет задействован тот же контроллер, что и отрендерил текущую страницу с формой. Нам это не подходит, ибо ТЗ звучит так

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

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

Но пока такого контроллера нет (а, значит, и маршрута). Чтобы вообще не забыть об actions укажем actions="", потом допишем новый маршрут, когда сделаем новый контроллер.

Что касается третьего вопроса, то данные из формы будем передавать POST-запросом.

Итог

<body>
  <!-- дописать action, когда появится контроллер следующей формы -->
  <form action="" method="POST">
  </form>
</body>

Шаг 2. Сделать заготовку контроллера, который будет рендерить страницу с формой

Собственно говоря, для этого у нас всё есть (точнее одно-единственное — шаблон страницы, который мы положили в правильное место).
Как я уже говорил, вся логика проекта в одном приложении, spaceflights. Идём в views.py оттуда и создаём там контроллер. image

def flight_details(request):
    return render(request, template_name='ship.html')

Так, что тут происходит:

  • контроллер принимает на вход http-запрос
  • возвращает http-ответ, который несет с собой строку со всей разметкой из файла ship.html
  • браузер считывает эту строку и отображает пользователю страницу, составленную из этой разметки

Напомню, что render из django.shortcuts, это сахарок или шорткат, функция, которая упаковывает несколько более простых действий, позволяя сократить количество кода.

Круто знать о шорткатах и писать мало кода, но ещё круче хорошо понимать, что за ними стоит.

Не используя render переписать код можно так:

from django.http import HttpResponse
from django.template import loader

def flight_details(request):
    template = loader.get_template('ship.html')
    return HttpResponse(template.render(request=request))

Кода на строчку больше, но для понимания фундамента всё гораздо яснее.
А фундамент в том, что любой контроллер в ответ на request (объект джанго-класса HttpRequest), должен возвращать объект джанго-класса HttpResponse или его наследников.

В данном случае объекту HttpResponse мы передаем шаблон, который был предварительно превращен в строку загрузчиком шаблонов. И с этой строкой дальше работает браузер.

Шаг 3. Привязать контроллер к маршруту (url) и починить ссылку с главной страницы

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

Т.е. складывается следующая цепочка: url-адрес -> контроллер -> отображение через контроллер шаблона страницы.
Мы начали с конца, т.к. без шаблона невозможен (в нашем случае) контроллер, а без контроллера маршрут.

Все маршруты приложения описываются в urls.py. Добавим туда новый:

# spaceflights/urls.py
urlpatterns = [
    ...
    path('flight/', flight_details, name='flight_details'),
]

Три аргумента для path:

  • сам маршрут (получится http://домен/flight)
  • вью-функция, которая обслуживает маршрут
  • описательное имя маршрута, чтобы потом не хардкодить ссылки на него, а пользоваться именем

Маршрут готов, его можно набирать в адресной строке браузера, но это не наш путь. Читаем снова ТЗ: открывается главная страница, в ссылке на страницу перехода к форме оформления заказа пока стоит заглушка.

Уберем заглушку, чтобы с главной страницы пользователь по ссылке попадал на страницу с нашей формой:

Было:

    <h2>
      <a href="#">
        Летим!
      </a>
    </h2>

Cтало:

    <h2>
      <a href="{% url 'flight_details' %}">
        Летим!
      </a>
    </h2>

Нам помог шаблонный тег {% url %}, мы передали ему имя маршрута из urls.py. и джанго уже сам превратит его в читаемый для браузера путь. Никакого харкода.
Но если бы захотелось бы похардкодить, то написали бы так
<a href="flight/">

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

Шаг 4. Начинаем создавать элементы формы с помощью Django

Сейчас на html-страницы наша форма обозначена так:

  <!-- дописать action, когда появится контроллер следующей формы -->
  <form action="" method="POST">
  </form>

Внутри формы нужно создать поля (теги input) разных типов (type=...), которые будут принимать данные.
Прежде чем писать html-разметку для полей самостоятельно, максимально задействуем возможности джанго. Будем управлять разметкой формы с бэкенда.

Начальный шаг для создания джанго-формы (задача которой: а) взять на себя создание части разметки на странице, б) проверить поступившие из html-формы даннные), всегда один и тот же: нужно создать python-класс у нашей формы и унаследоваться от класс джанго-форм.

Принято формы размещать отдельно от контроллеров, поэтому создадим внутри приложения spaceflights новый файл forms.py image

Объявим класс джанго-формы, предварительно сделав необходимый импорт:

from django import forms

class OrderForm(forms.Form):
    ...

... это Python-объект ellipsis, который можно использовать аналогично заглушке pass (для самых любопытных короткая статья).

Круто, класс формы мы объявили, пока ни одного атрибута (поля) в форме нет.

Приступим к их созданию, руководствуясь ТЗ.

4.1 Первое поле: радиокнопки с выбором корабля

image

Нам нужно ответить, как минимум, на следующие вопросы (и так для каждого поля создаваемой нами Django-формы):

  1. Для какого поля в какой модели предназначены данные из конкретного поля html-формы?
  2. Как должно называться джанго-поле при рендеринге на html-страницe?
  3. Какой класс джанго-формы наиболее подходит для того, чтобы отрендерить поле в html-форме и проверить поступившие через него данные?
  4. Подходит ли нам дефолтный виджет (иначе говоря `<input type="такой-то"...>), который использует подобранное нами поле джанго-формы?

Отвечаем на 1-й вопрос
Вся html-форма собирает данные, которые нужны для модели Order

class Order(models.Model):
    ship = models.ForeignKey(to=Spaceship, on_delete=models.CASCADE,
                             verbose_name='корабль')
    passenger = models.CharField(
        max_length=10, verbose_name='пассажир', blank=True
    )
    flight_date = models.DateField(verbose_name='дата полета')

Конкретно через поле html-формы "На чем летим" передаются данные для поля ship модели Order.

Что мы из этого вынесли
Поле в джанго-форме должно называться ship, т.к. именно это название попадет в качестве атрибута name в тег input html-формы.
Т.е. наши радиокнопки будет с тегами типа <input type=... name="ship">. Это важно, т.к. словаре с данными из POST-запроса ключами будут как раз name'ы.
Конечно, можно как угодно назвать поле формы, но гораздо удобнее, когда не нужно каждый раз думать, по какому ключу искать данные из пришедшего запроса для конкретного поля модели.

Отвечаем на 2-й вопрос
При рендеринге поле должно называться На чем летим. За название любого поля джанго-формы отвечает атрибут label. Если его явно не указать при объявлении поля, то лэйблом будет название поля как атрибута класса нашей джанго-формы, только с большой буквы. Т.е. лэйблом будет Ship. Но нас это не устраивает.

Пруф из исходного кода джанго (модули forms.boundfield и forms.utils):

if self.field.label is None:
    self.label = pretty_name(name)
...

def pretty_name(name):
    """Convert 'first_name' to 'First name'."""
    if not name:
        return ''
    return name.replace('_', ' ').capitalize()

Что мы из этого вынесли При объявлении класса поля надо явно передать ему label="На чем летим".

Отвечаем на 3-й вопрос Какой класс джанго-формы наиболее подходит для того, чтобы отрендерить поле в html-форме и проверить поступившие через него данные?
В html-поле формы от пользователя требуется сделать выбор из нескольких вариантов, значит, среди классов полей джанго-форм нам нужно что-то с choices :)

Идём смотреть классы встроенных полей

Возможные кандидаты:

Поля с multiple в названии заворачиваем сразу, перед пользователем стоит не множественный, а единственный выбор.
forms.ChoiceField нам тоже не подходит, т.к. на вход принимает самостоятельно нами созданный кортеж из двухэлементных кортежей (один элемент будет value поля, а второй будет рендерится на экране).
Нам не нужно ничего создавать, корабли берутся из связанной с моделью Order модели Spaceship.

В итоге остается поле ModelChoiceField. Почитаем о нем внимательнее в документации:

Позволяет выбор единственного объекта модели, имеет смысл при отображении внешнего ключа.

Класс, ровно то, что нам нужно, поле ship модели Order как раз поле с внешним ключом (связь один-ко-многим).

Какие у нас обязательные аргументы для этого поля? Он один queryset. Логично, ведь нужно же откуда-то брать значения для полей, из которых будет выбирать пользователь.

Единственный обязательный аргумент: queryset A QuerySet of model objects from which the choices for the field are derived and which is used to validate the user’s selection. It’s evaluated when the form is rendered.

Что мы из этого вынесли Теперь внутри нашей джанго-формы мы можем объявить поле, все исходные данные у нас есть:

class OrderForm(forms.Form):
    ship = forms.ModelChoiceField(queryset=Spaceship.objects, label="На чем летим")

Отвечаем на 4-й вопрос
Подходит ли нам дефолтный виджет (иначе говоря `<input type="такой-то"...>), который использует подобранное нами поле джанго-формы?

Виджет — это объект питоновского класса, который встраивается в поле джанго-формы и отвечает в нем за то, какой конкретно html-разметкой будет рендерится поле в шаблоне. Поэтому, если вы хотели вводить цифры, а перед вами календарь, тут заслуга виджета (ну и разумеется поля джанго-формы, которое этот виджет использовало).

У каждого поля джанго-формы есть дефолтный виджет (что это за виджет написано в описании поля в документации). Но мы всегда можем указать наш собственный виджет.

Итак, у нас по дефолту select. А селекты и радиокнопки, как говорят в Одессе, это две большие разницы.
image

Заглянем в классы виджетов, которые доступны из коробки. image

Название нужного нам виджета говорит само за себя: RadioSelect.

Аналогично Select, но отображается в виде списка радио кнопок

Чтобы переопределить виджет поля, класс нового виджета нужно передать в параметре widget. В итоге код нашего поля выглядит так:

class OrderForm(forms.Form):
    ship = forms.ModelChoiceField(
        queryset=Spaceship.objects,
        label='На чем летим',
        widget=RadioSelect # переопределили виджет
    )

А отрендерено в шаблоне поле будет вот так:

<ul id="id_ship">
  <li>
    <label for="id_ship_0">
        <input type="radio" name="ship" value="1" required id="id_ship_0">
        Crew Dragon
    </label>
  </li>
  <li>
    <label for="id_ship_1">
      <input type="radio" name="ship" value="2" required id="id_ship_1">
      New Shepard
    </label>
  </li>
  <li>
    <label for="id_ship_2">
      <input type="radio" name="ship" value="3" required id="id_ship_2">
      VSS Unity
    </label>
  </li>
</ul>

Всё как и обещал джанго — радиокнопки (<input type="radio"...>), обернутые в маркированный список (теги ul, li). image

Уже ощутимый результат. Теперь нам нужно избавиться от черных маркеров (а может и вообще от тегов списка), а еще рядом с радиокнопками выводить релевантные картинки (те, которые привязаны в базе к конкретному кораблю). Этим мы займемся в следующий раз.

Остановка, чтобы осмотреться под капотом

НЕ обязательный раздел, можно почитать, если есть время. Цель — посмотреть, как устроено создание форм под капотом.

Сейчас код формы такой:

class OrderForm(forms.Form):
    ship = forms.ModelChoiceField(queryset=Spaceship.objects, label='На чем летим', widget=RadioSelect)

Посмотрим на класс forms.Form:

class Form(BaseForm, metaclass=DeclarativeFieldsMetaclass):
    "A collection of Fields, plus their associated data."

Собственно здесь всё, больше кода (кроме нескольких комментариев после докстринга) нет. Поэтому идём в родительский класс BaseForm, а также в метакласс DeclarativeFieldsMetaclass.

Как джанго отделяет среди атрибутов класса формы поля от других атрибутов и как у объекта формы появляется атрибут fields?
Чтобы было более понятно, о чем речь, добавлю еще один атрибут в класс нашей формы, атрибут field_order, это список полей, указывающий, в каком порядке их рендерить.

class OrderForm(forms.Form):
    ship = forms.ModelChoiceField(queryset=Spaceship.objects, label='На чем летим', widget=RadioSelect) 
    field_order = ['ship',]  

Вспоминаем основы ООП:

  1. Всё в Python — объекты.
  2. Классы сами по себе — объекты.

Привыкаешь всегда смотреть на классы только в формате класс(), т.е. когда объект класса вызывается и порождает другой объект — экземпляр класса.

Но давайте не будем создавать пока экземпляр класса нашей формы. А посмотрим, как создается сам класс формы.
За создание любого объекта в Python, в том числе класса, отвечает метод __new__. Чтобы переопределить поведение __new__ именно для классов-как-объектов, используют метаклассы. Собственно, это мы и видели в сигнатуре класса forms.Form — на второй позиции указан метакласс: metaclass=DeclarativeFieldsMetaclass.

Из описания метода __new__ этого метакласса мы и поймем, что делает джанго с атрибутами класса формы при создании этого класса (повторюсь — самого класса, не его экземпляра).

  1. cильно погружаться в этот исходный код не нужно, важные штуки я выделил комментариями (мои на русском, на английском — это комменты разработчиков джанго)
    def __new__(mcs, name, bases, attrs):
        # Collect fields from current class.
        current_fields = []
        for key, value in list(attrs.items()): # запускается цикл по всем парам "ключ-значение" атрибутов, которыми наделен класс
                                               # в нашем случае атрибута два: `ship` и `order_field`.  
            if isinstance(value, Field): # вот момент истины: оценивается, относится значение в атрибуте к класcу `Field`, классу полей джанго-форм
                current_fields.append((key, value)) # если относится, то атрибут пара "ключ-значение" заносится в список `current_fields`  
                attrs.pop(key) # а сам атрибут поля исключается (словарный метод `pop`) из числа атрибутов класса, т.е. теперь `OrderForm.ship` НЕ сработает  
        attrs['declared_fields'] = dict(current_fields) # зато у `OrderForm` появляется новый атрибут `declared_fields` и в нем как раз доступно поле `ship`.  

        new_class = super().__new__(mcs, name, bases, attrs)

        # Walk through the MRO.
        declared_fields = {}
        for base in reversed(new_class.__mro__):
            # Collect fields from base class.
            if hasattr(base, 'declared_fields'):
                declared_fields.update(base.declared_fields)

            # Field shadowing.
            for attr, value in base.__dict__.items():
                if value is None and attr in declared_fields:
                    declared_fields.pop(attr)
        # Итог создания класса нашей формы как самостоятельного объекта - два атрибута класса `base_fields` и `declared_fields`, в которых доступны 
        именно те атрибуты класса формы, которые являются полями.  
        new_class.base_fields = declared_fields
        new_class.declared_fields = declared_fields

Проверим. Возьмем класс нашей формы как отдельный объект (т.е. вызывать его, создавать другой объект - экземпляр класса, не будем).

order_form_class_as_obj = OrderForm

# к этому моменту `__new__` уже отработал, а значит, `OrderForm.ship`, должен вызвать ошибку.
order_form_class_as_obj.ship
...AttributeError: type object 'OrderForm' has no attribute 'ship' # действительно, атрибут `ship` исчез...

#...но должны появиться `declared_fields` и `base_fields` и там `ship` будет 
order_form_class_as_obj.base_fields # {'ship': <django.forms.models.ModelChoiceField object at 0x000001B27B3FD1F0>}
order_form_class_as_obj.declared_fields # {'ship': <django.forms.models.ModelChoiceField object at 0x000001B27B3FD1F0>}  
# всё работает, как и должно. Появились новые атрибуты-словари, и наше поле там. Ключ: название поля, значение: объект поля.  

# а вот атрибут `field_order`, раз не относится к классу `Field` (и его наследникам), должен быть по-прежнему доступен.  
order_form_class_as_obj.field_order # всё работает, возвращается список [<django.forms.models.ModelChoiceField object at 0x000001DE9F4BF2B0>]  

Продолжение выложу здесь в понедельник, 29 ноября, а в среду, 1 декабря в 19.00 по мск продолжим обсуждать джанго-формы на вебинаре.

Что почитать, освежить (но ни в коем случае не застревать, если что-то будет непонятно)


Работаем с джанго-формами. Часть 2

4.2 Дефолтный рендеринг формы не устраивает. Нужно более гибкое управление отображением

4.2.1. По каким правилам рендерится форма целиком и что можно изменить

Сейчас на странице наша форма выглядит так image

А в шаблоне так

<form actions="" method="POST">
  {% csrf_token %}
  {{ space_form }}
</form>

В шаблонной переменной space_form находится экземпляр нашей формы OrderForm (мы передали его в словаре context через контроллер).

Когда объект формы целиком размещается в верстке (как в нашем случае), у объекта формы срабатывает метод __str__(отвечает за строковое представление объекта), который, в свою очередь, вызывает метод формы as_table.

Вот как это выглядит под капотом:

#django.forms.forms.BaseForm
class BaseForm:
    ...
    def __str__(self):
        return self.as_table()

    def as_table(self):
        "Return this form rendered as HTML <tr>s -- excluding the <table></table>."
        return self._html_output(
            normal_row='<tr%(html_class_attr)s><th>%(label)s</th><td>%(errors)s%(field)s%(help_text)s</td></tr>',
            error_row='<tr><td colspan="2">%s</td></tr>',
            row_ender='</td></tr>',
            help_text_html='<br><span class="helptext">%s</span>',
            errors_on_separate_row=False,
        )

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

as_table оборачивает каждое поле формы в строку таблицы с невидимыми границами. Благодаря этому достигается выравнивание формы и каждое поле располагается строго под предыдущим.

Важный для понимания момент: за отображение html-тегов <input> и <label> для конкретного поля отвечает виджет этого поля. А вот методы as_... самой формы уже оборачивают теги каждого отдельного поля (оборачивают=заключают внутрь некой оболочки, оболочкой выступают например теги <p></p>), в итоге получается матрешка из html-разметки.

Помимо as_table, который срабатывает по умолчанию, есть методы as_p и as_ul, которые отличаются тем, в какой html-тег оборачивается каждое поле.
Если указать {{ space_form.as_p }}, каждое поле формы будет обернутов в абзацный тег <p></p>. Если указать {{ space_form.as_ul }}, каждое поле формы будет обернутов в списочный тег <li></li>.

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

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

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

4.2.2. Как перейти к управлению отображением каждого поля в отдельности

Есть два варианта.

Вариант 1.
Запустить цикл по объекту формы. Объект формы итерабелен, об этом нам говорит наличие методов __iter__ и __getitem__ под капотом (кстати, вопрос о том, что представляют из себя итераторы, итерабельные объекты в Python и как реализовать протокол итератора, частый вопрос на собеседованиях).

class BaseForm:
    ...
    def __iter__(self):
        for name in self.fields:
            yield self[name]

    def __getitem__(self, name):
        """Return a BoundField with the given name."""
        try:
            field = self.fields[name]
        ...

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

<form actions="" method="POST">
  {% csrf_token %}
  <!-- запускаем цикл через специальные шаблонные теги -->  
  {% for field in space_form %}
    {{ field }} <!-- теперь у нас новая переменная в шаблоне, в которой "сидит" конкретное поле и через точку можно обращаться к различным его атрибутам -->
  {% endfor %}
</form>

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

<form actions="" method="POST">
  {% csrf_token %}
  {% for field in space_form %}
  <div class=...> <!-- каждое поле будет обернуто html-тег div одного и того же класса, в классе описываем нужные css-стили -->
    {{ field }}
  </div>
  {% endfor %}
</form>

Важный момент: если вдруг страница отобразилась не в том виде, в каком вы ожидали, проверьте расположение между собой html-тегов и шаблонных тегов, управляющих разметкой.
Например, не выпал ли закрывающий html-тег из цикла (оказался под, а не над {% endfor %}). Или, наоборот, в цикл попали лишние html-теги, которые вы и не собирались повторять.

Вариант 2.
Взять напрямую интересующее поле, указав через точку его имя (имя атрибута в классе вашей формы). Очень частое выражение в Python, да и других языках, точечная нотация (dot notation). Ничего сверхъестственного это за собой не несет, это доступ к атрибуту объекта с использованием точки.

В нашем случае до поля выбора кораблей мы доберемся так {{ space_form.ship }}.

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

4.2.3 Field и BoundField

Когда мы объявлем поле формы, например, так

ship = forms.ModelChoiceField(queryset=Spaceship.objects, label='На чем летим', widget=RadioSelect)

мы создаем объект конкретного поля, который, в конечном итоге, через цепочку наследования, всегда является объектом класса Field
(forms.ModelChoiceField наследник forms.Field, forms.CharField наследник forms.Field и т.д.).

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

Заглянем под капот джанго:

class Field:
    ...
    def __init__(self, *, required=True, widget=None, label=None, initial=None,
                 help_text='', error_messages=None, show_hidden_initial=False,
                 validators=(), localize=False, disabled=False, label_suffix=None)

У поля, например, будет атрибут .required, от значения которого зависит, пропустит ли is_valid отсутствие данных для поля или нет.

Есть атрибут label, о нем мы уже говорили выше, есть initial, куда можно передать начальное значение (value) поля и т.д.

Чтобы в python-коде (например, в forms.py или в коде контроллера), добраться до поля, нужно обратиться к атрибуту fields формы. fields — это словарь, поэтому далее используем квадратные скобки, внутри ключ - название поля. Так мы добираемся к объекту поля.
Например, если в переменной space_form находится экземпляр формы класса OrderForm, то к объекту поля ship мы доберемся так space_form.fields['ship']. И далее через точечную нотацию мы уже можем работать с различными атрибутами поля.

Промежуточный итог:

  • все поля формы получают атрибуты класса Field и его наследников (классов, отвечающих за поля конкретного типа)
  • все поля формы помещаются в словарь в атрибуте fields формы, доступ к ним такой `объект_формы.fields['название_поля_формы']

К полю формы в python-коде можно добраться ещё одним способом: объект_формы['название_поля']. Но — внимание — так мы получим не поле непосредственно, а объект класса BoundField.

На заметку. Когда мы любой python-объект (совсем не обязательно словарь) пишем в формате объект['какая_то_строка'], т.е. применяем квадратные скобки (square brackets notation), то вызывается метод __getitem__, определенный в классе этого объекта. Именно он отвечает за то, что будет происходить дальше. Если метод внутри класса не определен, то поднимется исключение TypeError: 'название_класса' object is not subscriptable. Иными словами, если вы вдруг получаете ошибку is not subscriptable, значит, вы не к тому объекту приставили квадратные скобки.
Объекты джанго-форм subscriptable, к ним можно применить квадратные скобки. При указании в качестве ключа названия поля, под капотом сработает field.get_bound_field(self, name). Т.е. вернется объект класса BoundField.

BoundField — это класс обертка над объектом нашего поля, т.е. объектом класса Field.

Поскольку BoundField — это обертка, то через нее, с помощью атрибута field можно добраться до объекта нашего поля.

Т.е. space_form.fields['ship'] и space_form['ship'].field приведут к одному и тому же — к объекту ModelChoiceField, наследнику Field.

У BoundField есть свои собственные атрибуты, которые позволяют не пробираться к field, а сразу вытащить какие-то из его атрибутов.

Например, у BoundField есть атрибут help_text. Такой же атрибут есть и у Field, в нем мы можем привести какой-то поясняющий текст для поля формы и отобразить его рядом с полем.

Соответственно, обращение к space_form.fields['ship'].help_text и space_form['ship'].field.help_text дадут один и тот же результат.
Однако у BoundField есть далеко не все атрибуты Field.

Например, чтобы проверить, обязательно поле или нет, нельзя использовать space_form.fields['ship'].required, потому что атрибута required у BoundField нет.

И теперь самое главное 😄: почему это всё так важно? Потому что в шаблоне страницы при цикле по полям формы или при доступе к полю напрямую используется как раз BoundField.

Поэтому, в {{ space_form.ship.какой_то_атрибут }} в качестве "какого_то_атрибута" можно выбрать только то, что перечислено в атрибутах BoundField, потому что {{ space_form.ship }} в шаблоне — это то же самое, что space_form['ship'] в python-коде.

Если же через шаблонную переменную с полем нужно добраться до атрибутов из класса Field, которых нет в BoundField, того же required, то нужно использовать переходник в виде атрибута field, т.е. будет так {{ объект_формы.название_поля_формы.field.атрибут }}.

Попробуйте в шаблон страницы вставить {{ space_form.ship.field.required }}.

<body>
  <div class='container'>
    <h2>Заказ полета</h2>
    {{ space_form.ship.field.required }} <!-- добрались до атрибута required класса Field через атрибут field класса BoundField -->
  </div>
</body>

Результат: image

На странице мы увидели значение атрибута requiredTrue. Поле обязательно.

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

Отмечу, что if можно было бы вставить непосредственно в атрибут class, но для большей иллюстративности я сделал два разных div.

<form actions="" method="POST">
  {% csrf_token %}
  {% for field in some_form %} <!-- цикл по любой джанго-форме -->
     {% if field.field.required %}
      <div class='класс_с_особым_стилем_для_ОБЯЗАТЕЛЬНЫХ_полей'>
        {{ field }}
      </div>
     {% else %}
      <div class='класс_с_особым_стилем_для_НЕобязательных_полей'>
        {{ field }}
      </div>
     {% endif %}
  {% endfor %}
</form>

4.2.4. Рендерим поле ship отдельно

Обратимся в шаблоне к полю напрямую.

  <form actions="" method="POST">
    {% csrf_token %}
    {{ space_form.ship }}
  </form>

image

Что поменялось? В верстке исчезла обертка в виде табличных тегов (которые раньше давал form.as_table()), но это незаметно. Зато видно, что исчез лейбл поля, нет На чем летим.

И здесь нужно запомнить важное правило: при непосредственном управлении полем в разметке нужно учитывать, что оно не монолитно. Оно состоит из целого ряда элементов: само поле (например, поле для ввода текста), лейбл поля, вспомогательный текст (help text), текст ошибки, которые вернет бэкенд после проверки поля и т.д.
Всё это нужно самостоятельно указывать в шаблоне, используя атрибуты BoundField`.

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

  <form actions="" method="POST">
    {% csrf_token %}
    {{ space_form.ship.label }} <!-- label, один из атрибутов BoundField -->
    {{ space_form.ship }}
  </form>

Результат image

Лэйбл вернулся.

4.2.5 Особенности полей с выбором одного или нескольких предложенных значений

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

За рендеринг каждой отдельной радиокнопки отвечает "подвиджет" (subwidget) и в качестве лейблов они используют названия объектов из переданного при создании поля набора записей.

Иными словами, через BoundField.label мы управляем лейблом всего набора радиокнопок (label всего поля ship), а не названиями каждой радиокнопки в отдельности.

Можно ли управлять отдельными виджетами (собственно говоря, каждым конкретным инпутом-радиокнопкой)? Да, можно.

Дело в том, что объект класса BoundField (а именно с ними мы работаем в шаблоне), итерабельны, т.е. по ним можно пройтись циклом.
Но что именно будет перебирать цикл? Смотри на реализацию метода __iter__ у BoundField

    def __iter__(self):
        return iter(self.subwidgets)

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

Каждый сабвиджет (в нашем случае каждая радиокнопка) относится к классу BoundWidget и содержит, в частности, такие атрибуты и методы:

  • tag — сама кнопка (чек-бокс и т.п.), без подписи
  • choice_label — подпись к кнопке (чек-боксу и т.п.)
  • id_for_label — айдишник радиокнопки (в частности, нужен, чтобы связать надпись с конкретной кнопкой)

4.2.6 Берем управление отображением поля выбора кораблей полностью в свои руки

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

Попробуем такой пример

<form actions="" method="POST">
  {% csrf_token %}
  <!-- название всего поля (всей группы радиокнопок)-->
  {{ space_form.ship.label }}
  <!-- запускаем цикл по полю == перебираем все радиокнопки -->
  {% for radiobutton in space_form.ship %}
    {{ radiobutton }}
  {% endfor %}
</form>

Смотрим, что получилось
image

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

О том, что дефолтная обертка радиокнопок в теги html-списка исчезла. Мы взяли вопрос рендеринга каждой кнопки под личный контроль и джанго отдаёт только разметку для <input> и для <label>.

Вот как сейчас выглядит разметка группы радиокнопок — просто подряд идущие лейблы и инпуты, никаких оберток

<!-- название всего поля (всей группы радиокнопок)-->
На чем летим
<!-- запускаем цикл по полю == перебираем все радиокнопки -->
<label for="id_ship_0">
  <input type="radio" name="ship" value="1" id="id_ship_0" required>
   Crew Dragon
</label>
<label for="id_ship_1">
  <input type="radio" name="ship" value="2" id="id_ship_1" required>
  New Shepard
 </label>
<label for="id_ship_2">
  <input type="radio" name="ship" value="3" id="id_ship_2" required>
   VSS Unity
</label>

Сравните эту разметку, что была при рендеринге поля ship целиком.

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

Как уже отмечал, можно пойти ещё дальше и управлять отдельно отображением самой кнопки и подписью к ней.

Давайте попробуем этот инструмент: оформим подпись к каждой кнопке текстом красного цвета.

<!-- запускаем цикл по полю == перебираем все радиокнопки -->
{% for radiobutton in space_form.ship %}
  <!-- рендерим саму кнопку -->
  {{ radiobutton.tag }}
  <!-- рендерим подпись, переопределив тег label, чтобы добавить новый стиль -->
  <label for="{{ radiobutton.id_for_label }}" style="color: red;">
    {{ radiobutton.choice_label }}
  </label>
{% endfor %}

Что мы сделали:

  • запустили цикл по радиокнопкам (цикл по subwidgets)
  • сначала отобразили в разметке саму кнопку
  • потом дописали собственный тег label, в который добавили css-свойство color со значением red (делает текст красным)
  • в label мы также добавили айдишник радиокнопки, чтобы связать подпись и кнопку
  • отобразили подпись.

Результат image

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

Продолжение выложу здесь в пятницу, 3 декабря, а в среду, 1 декабря в 19.00 по мск продолжим обсуждать джанго-формы на вебинаре.


Что почитать (освежить в памяти)

Работаем с джанго-формами. Часть 3

4.3. Прикручиваем картинки к радиокнопкам

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

4.3.1. Посмотрим детально, как работает ModelChoiceField

Что сейчас из себя представляет поле spac_ship в нашей джанго-форме?

Это поле класса ModelChoiceField. Логика этого поля такова:

  • взять набор записей из базы;
  • у каждой записи взять уникальный идентификатор (по умолчаню это первичный ключ (id, pk));
  • отобразить в html поле, предполагающее выбор из нескольких значений — радиокнопки, чекбоксы, выпадающий список;
  • каждому составному элементу html-поля (если, например, поле в виде радиокнопок, то каждой радиокнопке) присваивается атрибут value, значением которого является тот самый уникальный идентификатор записи.

Пример: набор записей в БД состоит из 3 записей с id 1, 2, 3 соответственно.
Если этот набор передать в ModelChoiceField с виджетом RadioSelect, то на странице появится три input type="radio" с value 1, 2 и 3 соответственно.

Если пользователь выберет радиокнопку, за которой стоит value=1, значит, на бэкенде мы будем обращаться к соответствующей таблице в БД к записи с id 1.

Ещё больше усилим пример :)

Заглянем в БД, в таблицу Spaceship. Чтобы открыть базу sqlite, я воспользуюсь очень удобным (да-да, звучит, как в рекламе) браузером DB Browser for SQLite.
Если ещё не установили, сделайте это, куда удобнее смотреть базу через браузер, чем через консоль. Вот ссылка — https://sqlitebrowser.org/dl/.

image

У нас три записи: id 1 — корабль Crew Dragon, id 2 — корабль New Shepard, id 3 — корабль VSS Unity.

Теперь посмотрим, как мы создавали поле ship в джанго-форме:

    ship = forms.ModelChoiceField(
        queryset=Spaceship.objects,
        label='На чем летим',
        widget=RadioSelect
    )

В параметр queryset мы передали Spaceship.objects, что равнозначно Spaceship.objects.all().

Пруфы, почему равнозначно? Вот кусочек кода из-под капота поля ModelChoiceField: self._queryset = None if queryset is None else queryset.all()

Итак, в параметре queryset мы передали все записи (а всего их 3) из модели Spaceship.

Это означает, что наше поле джанго-формы настрогает ровно 3 сабвиджета <input type="radio">.

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

Разумеется, мы можем взять в качестве queryset не все записи для модели, а какую-то часть.
Возьмем кверисет из одной записи Spaceship.objects.filter(id=1) (помним, что метод filter в джанго ORM возвращает именно кверисет, даже если внутри одна запись).

  ship = forms.ModelChoiceField(
      queryset=Spaceship.objects.filter(id=1),
      label='На чем летим',
      widget=RadioSelect
  )

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

image

4.3.2. Откуда и в каком виде брать картинки кораблей

В модели Spaceship предусмотрено поле image:

class Spaceship(models.Model):
    ...
    image = models.ImageField(upload_to=get_upload_path,
                              verbose_name='изображение')

Кстати, что за get_upload_path? Это собственная функция, которая формирует путь к сохраняемой картинке.
Благодаря этой функции картинки не валятся скопом в папку media, а для каждой картинки внутри media автоматом создаётся своя подпапка

Физически загруженные картинки хранятся в директории, которую мы установили в settings.py по ключу MEDIA_ROOT. В нашем случае это
MEDIA_ROOT = BASE_DIR / 'media' (о том, что скрывается за BASE_DIR, я очень подробно писал в первой части пособия).

image

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

Для того, чтобы отобразить картинку в html-странице, нам как раз нужен ее адрес. Его мы укажем в атрибуте src тега img.

Добраться до адреса картинки в базе можно так: объект_записи.название_поля_для_картинок.url.

Если, к примеру, в переменной spaceship_obj будет запись из таблицы Spaceship, то к адресу картинки, привязанной к этой записи, мы доберемся так: spaceship_obj.image.url.

4.3.3. В чем проблема

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

Заглянем в шаблон, еще раз посмотрим, как на странице появляются радиокнопки:

{% for radiobutton in space_form.ship %}
  <!-- рендерим саму кнопку -->
  {{ radiobutton.tag }}
  <!-- рендерим подпись, переопределив тег label, чтобы добавить новый -->
  <label for="{{ radiobutton.id_for_label }}">
    {{ radiobutton.choice_label }}
  </label>
{% endfor %}

В качестве итерируемого объекта выступает набор сабвиджетов, очень упрощая — просто кусочков html-разметки <input type="radio"...>

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

Иными словами, никакого волшебного radiobutton.obj.image.url у нас нет.

4.3.4. Решаем проблему: создаём zip-итератор в контроллере

Если сфокусироваться на текущем коде шаблона страницы, то становится понятным, что мы:
а) должны оставить имеющийся цикл, на каждой итерации которого появляется кнопка и подпись к ней
б) должны дополнить цикл еще одной переменной, за которой будет стоять объект записи из кверисета, переданного в ModelChoiceField
в) адрес картинки этой записи мы будем на каждой итерации включать в тег img и показывать картинку рядом с кнопкой

Чтобы на каждой итерации у нас было две переменных, нам нужны два итерируемых объекта.

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

Сделать такой вот составной итерируемый объект нам поможет стандартный питоновский итератор zip.

Мы его создадим в контроллере и через контекст передадим в шаблон и тогда уже доработаем цикл.

Идём в контроллер (см. комментарии к новому коду в сниппете)

def flight_details(request):
    form = OrderForm()
    # собрали адреса всех картинок из кверисета
    ship_images = [ship.image.url for ship in form.fields['ship'].queryset]
    # создали zip-итератор
    ship_field = zip(form['ship'], ship_images)
    return render(
        request,
        template_name='ship.html',
        context={
            'space_form': form,
            # передали итератор в контекст шаблона в переменной ship_field
            'ship_field': ship_field
        }
    )

Что мы сделали:

  1. Взяли набор записей, для этого мы обратились к полю ship через form.fields['ship'] и дальше взяли значение атрибута queryset. Это тот самый queryset, который передавался полю в джанго-форме
  2. Тем самым мы гарантировали, что мы взяли именно тот набор записей, на основе которого рендерятся радиокнопки, и именно в том порядке, в котором они будут расположены
  3. С помощью list comprehension мы прошлись по набору, взяли из каждой записи адрес картинки и составили список из этих адресов
  4. Создали zip-итератор:
    • на первом месте поле (внимание - в данном случае мы взяли поле как объект класса BoundField, только он итерабелен)
    • за полем скрываются три радиокнопки (по числу записей в кверисете)
    • на втором месте список адресов картинок (и их, разумеется тоже три, т.к. они из того же набора записей)
    • поскольку источником обоих списков является один и тот же набор записей, мы уверены в том, что каждая пара "радиокнопка-картинка" будет правильной.
  5. Передали итератор в контекст шаблона (переменная ship_field).

4.3.5. Решаем проблему: модифицируем цикл создания радиокнопок в шаблоне

Теперь мы запустим цикл в шаблоне не по полю ship, а по нашему новому zip-итератору в переменной ship_field.

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

<!-- теперь на каждой итерации у нас 2 переменных -->
{% for radiobutton, img_url in ship_field %}
  <!-- рендерим саму кнопку -->
  {{ radiobutton.tag }}
  <!-- рендерим подпись, переопределив тег label, чтобы добавить новый -->
  <label for="{{ radiobutton.id_for_label }}">
    {{ radiobutton.choice_label }}
    <!-- рендерим картинку, прикрепленную к записи из Spaceship -->
    <img src="{{ img_url }}" style="width: 200px; height: auto;">
  </label>
{% endfor %}

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

Результат 🔥

image

4.4. Создаём поле выбора даты полета

К полю согласно ТЗ три требования:

  • оно должно называться "Когда летим"
  • выбор даты осуществляется с помощью календаря
  • должны быть доступны не раньше +1 месяц от текущей даты (тут мы сейчас немного упростим и заменим условие на +4 недели, так проще будет дельту времени задать)

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

В данном случае мы выбираем поле forms.DateField. Начальный код поля в джанго-форме будет выглядеть так:

flight_date = forms.DateField()

Даже не заглядывая в верстку, мы знаем, как это поле будет отображено

image

Дефолтным лейблом при этом будет Flight date.

4.4.1. Задаём подпись к полю

Поменять лэйбл просто, надо явно передать нужное значение полю в параметре label: flight_date = forms.DateField(label='Когда летим')

image

4.4.2. Меняем тип инпута

Разобраться с лейблом было легко, но есть куда более серьезная проблема. Виджет DateInput рендерит обычное текстовое поле ввода.
Пользователь должен напечатать дату! Помимо того, что это выглядит, мягко говоря, архаично, так ещё и формат ввода даты должен быть строго определенным.

Читаем документацию к полю DateField:

If no input_formats argument is provided, 
the default input formats are taken from DATE_INPUT_FORMATS if USE_L10N is False, 
or from the active locale format DATE_INPUT_FORMATS key if localization is enabled.

Никаких особенных форматов даты мы в настройках нашего проекта не устанавливали, но у нас установлена локализация в settings.py (строчка USE_L10N = True) поэтому на вход будут ожидаться следующие форматы:

DATE_INPUT_FORMATS = [
    '%d.%m.%Y',  # '25.10.2006'
    '%d.%m.%y',  # '25.10.06'
]

Это дефолтные форматы даты для русской локали.

Посмотрев, какие вообще могут быть типы у html-инпутов, мы увидели, что есть такой type="date", который как раз отображает календарик. Ровно то, что нам нужно.

type — это один из возможных атрибутов html-тега input.

Управлять тегами инпутов можно через словарь attrs виджета поля джанго-формы.

  1. Пробираемся к виджету. Для этого обращаемся к параметру widget поля и явно указываем в нем объект виджета:
flight_date = forms.DateField(
   label='Когда летим',
   widget=DateInput()
)
  1. Теперь в параметрах виджета нужно указать attrs и передать этому параметру словарь с нужными нам атрибутами (парами "ключ"-"значение", которые мы хотим видеть внутри <input>).
flight_date = forms.DateField(
   label='Когда летим',
   widget=DateInput(
      'attrs': {'type': 'date'}
   )
)

Вот. Теперь у нас будет рендерится <input type="date"...>. Проверим

image image

Ура! Календарь готов.

4.4.3. Прикручиваем валидатор на бэкенде

Прежде чем перейти к решению задачи на конкретном поле, посмотрим на вопрос валидации в масштабе.

Самое главное: четко различать уровни валидации.

1. Валидация на фронте
Это валидаторы, которые включены в html-теги, либо джаваскрипт-код, который, опять же на строне браузера, может проверять вводимые пользователем данные.

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

Когда видим в верстке строку типа <input type="text" required minlength="10">, это означает, что есть поле для ввода пользователем текста, и у этого поля 2 валидатора:

  • во-первых, проверяется, что поле действительно заполнено (валидатор required)
  • во-вторых, проверяется, что длина введенного текста не менее 10 символов (валидатор minlength)

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

Если данные пользователя не проходят проверку фронтовыми валидаторами, то сабмит формы (нажатие условной кнопки "Отправить") не приводит к ее отправке на бэк.

Вместо этого браузер сообщает пользователю, в каких полях он ошибся.

Суперважный момент: установка валидаторов на фронте никак не влияет на бэкенд. Иными словами, если по каким-то причинам фронт пропустил невалидные данные, то на бэкенде они будут с радостью приняты.

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

Это связано с тем, что, как мы уже говорили, джанго-форма выступает сразу в двух ролях — и как инструмент рендеринга html-разметки (а значит, можно включить в нее и фронтовые валидаторы), и как инструмент проверки данных на бэкенде.

Пример: required. По дефолту, если поле джанго-формы создано на бэкенде с required=True, то и на фронте оно будет отрендерено с required в инпут теге.

Следовательно, даже если фронт пропустит пустое значение в этом поле, бэк подстрахует и вернет форму с ошибкой.

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

Поэтому, если не уверены, что фреймворк за вас продублирует валидатор с фронта на бэкенд или наоборот, сделайте это сами.

2. Валидация в джанго-форме
Это валидация на бэкенде. Она независима от валидации на фронте и запускается при передаче в объект формы полученных в POST-запросе данных и вызове .is_valid() либо .errors (errors запустит is_valid).

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

3. Валидация на уровне полей модели
Это тоже валидация на бэкенде, но пока мы ее затрагивать не будем. Но вернемся к ней, когда будем рассматривать класс ModelForm.

4.4.4. Валидатор минимальной даты

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

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

Важный момент. validators — это всегда список, даже если валидатор будет всего один.

flight_date = forms.DateField(
    label='Когда летим',
    # внимание: даже если валидатор один, он должен передаваться в списке
    validators=[],
    widget=DateInput(
        attrs={
            'type': 'date',
        }
    )
)

Нам нужен валидатор минимального значения даты (+4 недели от текущей). Для нам подойдет стандартный джанго-валидатор MinValueValidator.
Его особенность в том, что он может принимать не только какие-то простые значения, но и функции, возвращающие значение, которое принимается за минимальное.

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

def get_min_date():
    """Возвращает текущую дату, увеличенную на 4 недели."""
    return datetime.date.today() + datetime.timedelta(weeks=4)

Эту функцию (без вызова!) передадим в валидатор, а его в свою очередь поместим в список validators. Получится так:

flight_date = forms.DateField(
    label='Когда летим',
    validators=[MinValueValidator(get_min_date)],
    ...

Теперь в какой бы день пользователь не обратился к форме, она не пропустит дату, которая меньше чем дата обращения + 4 недели.

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

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

4.4.5. Прикручиваем валидатор на фронте

Мы помним: чтобы сделать валидацию на фронте, нужно указать валидатор в html-разметке, в теге input. А ещё мы помним, что составом тегов внутри input мы можем управлять прямо из кода джанго-формы с помощью словаря attrs виджета поля.

Зная, что в input type="date" за валидацию минимальной даты отвечает атрибут min, передадим его в attrs

flight_date = forms.DateField(
    label='Когда летим',
    validators=[MinValueValidator(get_min_date)], # проверка минимальной даты на бэкенде
    widget=DateInput(
        attrs={
            'type': 'date',
            'min': get_min_date # проверка минимальной даты на фронтенде
        }
    )
)

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

  • рендер под капотом джанго увидит, что в атрибуте min вызываемый (callable) объект
  • этот объект (наша функция) будет вызван
  • результат (к примеру, по состоянию на 3 декабря) — datetime.date(2021, 12, 31) (объект datetime)
  • в итоге будет возвращено строковое представление этого объекта (str(datetime.date(2021, 12, 31)), т.е. "2021-12-31"
  • в верстку пойдёт min="2021-12-31". Именно в таком (международном) формате YYYY-MM-DD и ожидает значение атрибут min тега input type="date".

Что почитать, освежить в памяти:

Полезные инструменты:

Работаем с джанго-формами. Часть 4

5. Создаём поле для выбора валюты платежа

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

  1. Назовем поле currency.

  2. Класс поля джанго-формы — ChoiceField. Не ModelChoiceField, а именно ChoiceField, т.к. корреспондирующего поля в модели для данного поля формы нет.
    Иными словами, в модели нет такого поля, данными из которых мы бы нагенерировали опции выбора в форме (как мы это сделали для поля выбора корабля).

  3. Из описания поля ChoiceField в документации мы видим, что его стандартный виджет Select. А стандартный виджет Select, в свою очередь, рендерится в в виде выпадающего списка опций.

<select>
  <option ...>...
</select>

В рамках нашего ТЗ дефолтный виджет нас полностью устраивает, ничего переопределять не будем.

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

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

Для нашей ситуации choices будет таким

(
   ('RUB', 'Российский рубль'),
   ('USD', 'Доллар США')
)

В выпадающем списке на странице в браузере пользователь будет видеть Российский рубль и Доллар США, а на бэкенд в составе POST-запроса будет приходить либо пара "currency": "RUB", либо пара "currency": "USD".

  1. Согласно ТЗ в форме на странице поле должно называться "Валюта платежа", поэтому передадим соответствующую строку для параметра label поля.

В итоге код поля в джанго-форме будет таким

currency = forms.ChoiceField(
    choices=(
        ('RUB', 'Российский рубль'),
        ('USD', 'Доллар США')
    ),
    label='Валюта платежа'
)

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

 <div style="margin-top: 30px;">
   {{ space_form.currency.label }}
   {{ space_form.currency }}
 </div>

Отрендерится поля вот так:

image

Полный код формы теперь такой:

def get_min_date():
    """Возвращает текущую дату, увеличенную на 4 недели."""
    return datetime.date.today() + datetime.timedelta(weeks=4)


class OrderForm(forms.ModelForm):
    ship = forms.ModelChoiceField(
        label='На чем летим',
        queryset=Spaceship.objects,
        widget=RadioSelect,
    )
    flight_date = forms.DateField(
        label='Когда летим',
        validators=[MinValueValidator(get_min_date)],
        widget=DateInput(
            attrs={
                'type': 'date',
                'min': get_min_date
            }
        )
    )
    currency = forms.ChoiceField(
        label='Валюта платежа',
        choices = (
            ('RUB', 'Российский рубль'),
            ('USD', 'Доллар США')
        )
    )

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

  • что такое ModelChoiceField?
  • для чего нужно явно указывать widget=RadioSelect?
  • а вообще, что такое виджет?
  • в чем отличие валидатора в строке validators=[MinValueValidator(get_min_date)], от валидатора в строке 'min': get_min_date
  • и т.д.

Если возникают трудности, лучше перечитать материал выше. Потому что цель учебы не в том, чтобы быстро проскочить по материалу. Это ничего не даст.

Цель — учиться писать код осмысленно, на 100% понимая, что вы хотите сказать или что хотел сказать тот, чей код вы читаете (а на практике читать чужой код придется куда чаще, чем писать собственный).

6. Рефакторим джанго-форму: используем класс ModelForm

6.1. Минимальный набор кода для создания модельной формы

До текущего момента мы создавали форму на основе джанго класса Form, наследника BaseForm.
У BaseForm есть еще один класс-наследник — класс ModelForm.

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

Иными словами, когда можно выстроить цепочку поле формы -> поле модели.

У нас как раз такой случай — 2 из 3 полей формы можно сопоставить с полями модели Order:

  • поле джанго-формы ship -> поле ship модели Order
  • поле джанго-формы flight_date -> поле flight_date модели Order.

Создание формы на базе класса ModelForm позволяет нам доверить создание полей формы самому джанго.

В минимальном варианте требуется следующее:

  • прописать внутри класса формы класс Meta
  • в классе Meta два атрибута:
    • model — модель, которую обслуживает джанго-форма
    • fields — список (или кортеж) полей модели (важно: именно модели, не формы), для которых джанго автоматически создаст поля в нашей форме.

6.2. Что стоит за "магией" создания модельной формы

Вот что представляет из себя модельная форма, если бы мы описали поля самостоятельно через "обычную" форму.

          Модельная форма                            "Обычная" форма
-------------------------------------------|----------------------------------------
class OrderForm(forms.ModelForm):          |class OrderForm(forms.Form):
                                           |    ship = forms.ModelChoiceField(label='Корабль', queryset=Spaceship.objects.all())
    class Meta:                            |    flight_date = forms.DateField(label='Дата полета')
        model = Order                      |
        fields = ['ship', 'flight_date']   |

Вот эту "магию" по превращению 'ship из метаопции fields в ship = forms.ModelChoiceField(label='Корабль', queryset=Order.objects.all()) джанго делает за нас.

Разберем, как он это делает:

  1. Джанго идёт в модель Order и смотрит, какой класс у поля ship этой модели. Видит, что класс ForeignKey
  2. Джанго смотрит "карту" соответствия классов полей модели классам полей формы. Видит, что классу поля модели ForeignKey соответствует класс поля формы ModelChoiceField.
  3. Поскольку поле ship модели Order через внешний ключ связано с моделью Spaceship, то атрибутом queryset для ModelChoiceField становится Spaceship.objects.all().
  4. Также джанго видит, что у поля ship есть verbose_name='корабль', т.е. человекочитаемое название, его он берет в качестве лейбла для поля формы.
  5. В качестве названия самого поля джанго берет то же название, что и поле модели, т.е. ship.

Так и рождается поле джанго-формы ship = forms.ModelChoiceField(label='Корабль', queryset=Spaceship.objects.all()).

С полем flight_date происходят точно такие же манипуляции.

6.3. Как быть с полем currency, его же нет в модели Order

Создание модельной формы (формы-наследника класса ModelForm) не исключает возможности объявлять поля формы самостоятельно, а не поручать это джанго.

Это делается за пределами внутреннего класса Meta ровно также, как мы это делали, собирая "обычную" джанго-форму.

class OrderForm(forms.ModelForm):
    currency = forms.ChoiceField(
        choices = (
            ('RUB', 'Российский рубль'),
            ('USD', 'Доллар США')
        ),
        label='Валюта платежа'
    )

    class Meta:
        model = Order
        fields = ['ship', 'flight_date']

По сути, мы сказали "Джанго, создай за нас поля формы ship и flight_date на основе одноименных полей модели Order, а ещё в форме будет поле currency, его создание мы полностью берем на себя".

Что произойдёт, если указать currency в fields внутри Meta? Тут возможны два сценария:

  1. Поле currency явно объявлено вне Meta (так, как сейчас). Тогда ничего не произойдёт, джанго проигнорирует наличие currency в fields.
  2. Поле currency не объявлено явно. Тогда джанго пойдёт в модель Order искать поле currency, не найдёт его там и вы увидите ошибку
django.core.exceptions.FieldError: Unknown field(s) (currency) specified for Order

Отсюда вывод: указывать в fields названия полей, которых нет в модели, как минимум, бессмысленно, а как максимум всё сломает.

6.4. Автоматически созданные поля не устраивают. Что делать?

Да-да, доверив создание полей ship и flight_date полностью джанго мы сталкиваемся с теми же проблемами, которые решали, когда объявляли поля самостоятельно:

  1. Не устраивает дефолтный виджет select для поля ModelChoiceField, нужны радиокнопки, а не выпадающий список опций.
  2. Поле для даты опять не календарик, а окошко для ввода даты текстом, плюс никаких валидаторов на фронте
  3. Дефолтные подписи к полям не подходят.

Иными словами, джанго создал

ship = forms.ModelChoiceField(label='Корабль', queryset=Spaceship.objects.all())

а нам-то нужно

ship = forms.ModelChoiceField(label='На чем летим', queryset=Spaceship.objects, widget=RadioSelect)

т.е. требуется переопределить label и widget.

Вариант 1 Для того, чтобы переопределить некоторые (но не все!) атрибуты полей в модельной форме, в Meta есть набор опций:

  • labels — для переопределения подписей к полям
  • help_texts — для указания поясняющего текста к полям, который потом можно отрендерить рядом с полем
  • widgets — для переопределения виджетов полей
  • field_classes — для переопределения класса поля формы (если не устраивает дефолтное сопоставление полей или поле кастомного класса)
  • error_messages — для переопределения сообщений об ошибках по их кодам

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

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

    class Meta:
        model = Order
        fields = ['ship', 'flight_date',]
        widgets = {
            'ship': RadioSelect,
            'flight_date': DateInput(
                    attrs={'type': 'date', 'min': get_min_date}
                  )
            }
        labels = {
            'ship': 'На чем летим',
            'flight_date': 'Когда летим'
          }

Как видим, никакой магии — лейблы и виджеты, которые мы с вами указывали при объявлении полей самостоятельно, мы просто перенесли в соответствующие атрибуты внутри Meta.

Вариант 2
Поля ship и flight_date можно объявить явно, ровно так же как мы это делали в "обычной" форме.

class OrderForm(forms.ModelForm):
    ship = forms.ModelChoiceField(
        queryset=Spaceship.objects,
        label='На чем летим',
        widget=RadioSelect
    )
    flight_date = forms.DateField(
        label='Когда летим',
        widget=DateInput(
            attrs={'type': 'date', 'min': get_min_date}
        )
    )

    class Meta:
        model = Order
        fields = ['ship', 'flight_date', ]

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

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

Иными словами, если начали явно описывать поле вне Meta, то делайте это до конца.

Ещё важный момент: наверняка вы обратили внимание, что несмотря на то, что мы определили ship и flight_date явно, мы их оставили и в атрибуте fields класса Meta.

В чем смысл?

  1. Причина первая, банальная — без полей в fields мы вообще не можем создать модельную форму. Но зачем тогда использовать ModelForm, если можно создать "обычную" форму (наследника forms.Form)?
  2. Автоматическое создание полей формы — не единственная фишка ModelForm. Есть ещё как минимум две очень важных вещи:
  • при валидации модельной формы запускаются проверки не только на уровне формы, но и на уровне модели. Если мы создадим поле ship в обычной форме, то, разумеется, валидаторы, которые есть для одноименного поля в модели запускаться не будут (потому что связки "модель - форма" нет). В модельной же форме будут (если ship будет указано в fields внутри Meta).
  • в модельной форме есть методы save и save_m2m, возможности которых значительно упрощают создание записи в БД на основе данных из полей модельной формы.

Для поля формы currency всё остаётся так, как было написано выше, — поскольку корреспондирующего поля модели для него нет, то и в fields его указывать бессмысленно.

Какой вариант переопределения атрибутов полей модельной формы выбрать? Ответ прост — если вам нужно переопределить что-то, для чего предусмотрены опции в Meta (а именно виджет, вспомогательный текст, лейбл, класс поля, сообщения об ошибках), то воспользуйтесь вариантом 1. Если же опций в Meta не хватает (например, в Meta нет validators), тогда переопределяйте поля из fields явно, т.е. описывая их вне Meta.

Что почитать, освежить в памяти:

Работаем с джанго-формами. Часть 5

7. Как устроена валидация в джанго-формах

Независимо от того, какую форму мы создаём — "обычную" (наследника forms.Form) или модельную (наследника forms.ModelForm) — валидация на уровне формы устроена одинаково. Фишка модельной формы в том, что после валидации на уровне формы она подключает валидацию на уровне модели.

7.1. Как запустить валидацию данных в джанго-форме

Чтобы джанго-форма начала процедуру валидации нужны две вещи:

  1. Данные, которые нужно валидировать. Они передаются в параметре data при создании объекта формы.
    В качестве данных для проверки мы берем данные, которые пришли с фронтенда в словаре request.POST.
order_form = OrderForm(data=request.POST)
  1. Нужно запустить валидацию. Для этого нужен экземпляр джанго-формы с переданными ему данными для проверки и вызов у него одного из двух методов (атрибутов):
  • .is_valid()
  • .errors

Т.е. order_form.is_valid() и order_form.errors (обратите внимание, errors без скобок, т.к. это проперти) будут иметь один и тот же результат — запуск валидации.

Теперь сделаем экскурс в исходный код Django. Круто, когда не доверяешься чьим-то текстам (например, моим), а знаешь наверняка, потому что заглядывал под капот.

Важный момент: все выкладки из исходного кода даны по последней (4.0) версии Django, но они абсолютно актуальны и для версии 3.2 и для версии 2.2 (к вопросу о том, что не надо переживать, что вот вышла 4-я джанга, а мы изучаем 2.2 или 3.2, радикальные изменения больших кусков фреймворка происходят очень редко).

Код метода is_valid()

def is_valid(self):
    """Return True if the form has no errors, or False otherwise."""
    return self.is_bound and not self.errors

Видим, что is_valid возвращает True, если нет ошибок (not self.errors) и если в форму вообще передавались какие-то данные в аргументе data (is_bound).

Распутываем клубок и идем смотреть как устроен self.errors

@property
def errors(self):
    """Return an ErrorDict for the data provided for the form."""
    if self._errors is None:
        self.full_clean()
    return self._errors

Если в атрибуте _errors ничего нет (а на старте валидации и не может быть в принципе), то запускается метод full_clean экземпляра нашей формы.

Теперь исходный код метода full_clean формы

def full_clean(self):
    """
    Clean all of self.data and populate self._errors and self.cleaned_data.
    """
    self._errors = ErrorDict()
    if not self.is_bound:  # Stop further processing.
        return
    self.cleaned_data = {}
    # If the form is permitted to be empty, and none of the form data has
    # changed from the initial data, short circuit any validation.
    if self.empty_permitted and not self.has_changed():
        return

    self._clean_fields()
    self._clean_form()
    self._post_clean()

В этом коде сердце валидации и мы можем понять ее фундамент:

  1. Создаются два словаря — словарь для сбора данных об ошибках (self._errors) и словарь для сбора проверенных, не вызвавших ошибок, данных (self.cleaned_data).
  2. Одна за другой следуют три волны проверок:
  • методом _clean_fields()
  • методом _clean_form()
  • методом _post_clean()

Видим, что метод full_clean формы ничего не возвращает. Его задача запустить методы различных проверок и через них наполнить (если есть чем) словари ошибок и проверенных данных.

7.2. Первая волна валидации — метод _clean_fields()

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

7.2.1 Как работает метод clean поля

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

В request.POST пришел словарь {'ship': '1', 'flight_date': '2022-01-31', 'currency': 'RUB'}.

Для каждого ключа из этого словаря в джанго-форме есть одноименное поле.

Мы создаём экземпляр формы и помещаем туда данные order_form = OrderForm(data=request.POST). Сделав это, наша форма теперь считается "связанной данными" (is bound).

Запускаем валидацию order_form.is_valid().

Поскольку данные в форме есть, т.е. order_form.is_bound возвращает True, то запускается полноценная валидация.

Первым делом у формы срабатывает _clean_fields, это означает, что:

  • значение '1' передается полю формы ship (полю класса ModelChoiceField) и у этого поля вызывается метод clean, который проверяет значение 1
  • значение '2022-01-31' передается полю формы flight_date (полю класса DateField) и у этого поля вызывается метод clean, который проверяет значение 2022-01-31
  • значение 'RUB' передается полю формы currency (полю класса ChoiceField) и у этого поля вызывается метод clean, который проверяет значение 'RUB'.

К какому бы классу не относилось конкретное поле формы, все они в конечном счете наследники класса Field. Поэтому именно там мы посмотрим описание метода clean.

def clean(self, value):
    """
    Validate the given value and return its "cleaned" value as an
    appropriate Python object. Raise ValidationError for any errors.
    """
    value = self.to_python(value)
    self.validate(value)
    self.run_validators(value)
    return value

Код совершенно несложный:

  • сначала поступившее значение преобразуется методом to_python поля
  • затем проверяется методом validate поля
  • затем проверяется методом run_validators

Далее рассмотрим все три метода на примере поля flight_date и значения '2022-01-31'.

7.2.1.1 Метод to_python поля

Задача этого метода в каждом поле — нормализовать поступившую строку с данными, привести ее к какому-либо Python-объекту.
Каким-либо образом переопределять этот метод для полей "из коробки" (т.е. классов, которые предлагает джанго) вам не нужно.
Просто знайте, что такой метод есть и зачем он нужен.

В каждом классе полей джанго-формы логика to_python своя. Например, если поле класса IntegerField, то поступившие значение будет преобразовываться в целое число вызовом int(value).

У нас поле DateField и его логика to_python в том, чтобы поступившую строку 2022-01-31 преобразовать в объект datetime.date следующим образом

datetime.datetime.strptime(value, format).date()

В результате после to_python строка 2022-01-31 станет объектом datetime.date(2022, 1, 31).

7.2.1.2 Метод validate поля

Метода validateвполне может не быть в классе конкретного поля, тогда работает validate из класса-родителя Field

def validate(self, value):
    if value in self.empty_values and self.required:
        raise ValidationError(self.error_messages['required'], code='required')

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

Так же, как и в случае с to_python, переопределять это поведение вам вряд ли понадобится.

7.2.1.3 Метод run_validators поля

Этот метод запускает валидаторы, которые мы передали через параметр validators. Вспомним, как объявляли поле flight_date формы.

    flight_date = forms.DateField(
        label='Когда летим',
        validators=[MinValueValidator(get_min_date)], # валидаторы отсюда запускает `run_validators`  
        widget=...

Сам метод run_validators трогать не нужно, просто следует знать, что валидаторы можно передать списком через validators.

Теперь мы можем описать весь путь строки '2022-01-31' при обработке методом clean поля:

  1. to_python превращает в datetime.date(2022, 1, 31)
  2. validate пропускает дальше, т.к. для обязательного пришли данные
  3. run_validators запускает MinValueValidator(get_min_date), которые тоже пропускает объект даты, т.к. она не меньше минимальной

7.2.2. Что происходит после отработки метода clean поля?

Джанго ищет, а есть ли в классе нашей формы метод, название которого соответствовало бы формату def clean_<название_поля>. Вот как это описано в исходном коде

if hasattr(self, 'clean_%s' % name):
    value = getattr(self, 'clean_%s' % name)()
    self.cleaned_data[name] = value

Этот метод вы описываете самостоятельно и только тогда, когда проверок через to_python, validate и run_validators вам недостаточно.

В методе приводится нужная логика проверок и проверенное значение (в случае успеха) передается в словарь cleaned_data.

Остановимся и посмотрим, какая последовательность проверок у нас вырисовывается

is_valid() # старт валидации
    errors # второй вариант, как можно запустить валидацию
      full_clean()
        _clean_fields() # запуск цикла с перебором каждого поля формы
            # у каждого поля
            clean() # всегда
                to_python() # всегда
                validate() # всегда
                run_validators() # если передавали что-то в validators
            clean_<название_поля>() # если самостоятельно описали этот метод внутри класса формы

После вызова clean и (при наличии) clean_<название_поля_формы> работа _clean_fields заканчивается и в дело вступает следующий этап проверок — метод _clean_form() формы.

7.3. Как работает _clean_form и clean джанго-формы

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

Посмотрим исходный код метода _clean_form:

def _clean_form(self):
    try:
        cleaned_data = self.clean()
    except ValidationError as e:
        self.add_error(None, e)
    else:
        if cleaned_data is not None:
            self.cleaned_data = cleaned_data

Логика метода:

  1. Запустить метод clean формы
  2. Если этот метод поднимет исключение ValidationError, то добавить это исключение в словарь с ошибками
  3. Если метод отработает без ошибок, то есть два варианта:
  • ничего не делать, если успешно отработавший clean вернул None
  • если clean что-то вернул, то это "что-то" становится новым словарем валидированных данных всей формы.

По поводу последнего нужно несколько пояснений. None может вернуться в трех случаях:

  • функция (метод) заканчивается return без значения;
  • функция (метод) заканчивается return None;
  • функция (метод) в принципе не содержат инструкцию return, т.е. не возвращают ничего.

Как правило, при описании логики метода clean формы ничего не возвращают. Т.е. либо метод поднимает исключение (==проверка провалена), либо метод не возвращает ничего (т.е. исключение не поднялось, значит, проверка прошла успешно, и можно использовать ранее сформированный словарь cleaned_data).

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

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

Т.е. нам нужно сделать проверку, в которой будут участвовать значения из двух полей — поля ship и поля flight_date.

Напишем код этой проверки (комментарии по ходу кода):

def clean(self):
    # берем значение поля `flight_date` из словаря валидированных данных
    flight_date = self.cleaned_data['flight_date']
    # берем значение поля `ship` из словаря валидированных данных
    ship = self.cleaned_data['ship']
    # делаем запрос к таблице заказов - считаем, сколько уже заказов есть на тот же корабль и на ту же дату
    existing_orders = Order.objects.filter(
        flight_date=flight_date,
        ship=ship
    ).count()
    # если количество заказов больше или равно вместимости выбранного корабля
    # capacity - это поле таблицы Spaceship, в котором хранится информация о предельном числе мест на конкретном корабле
    if existing_orders >= ship.capacity:
        # поднимаем исключение с описанием ошибки
        raise forms.ValidationError(
            'К сожалению, на эту дату мест нет. '
            'Пожалуйста, выберите другую дату или другой корабль.'
        )

Заметьте, что наш метод clean ничего не возвращает (return нет). Потому что задача метода не добавить или изменить что-то в существующем словаре cleaned_data, а подтвердить его правильность путем дополнительных проверок с участием значений из нескольких полей.

Дополним схему валидации новыми методами:

Остановимся и посмотрим, какая последовательность проверок у нас вырисовывается

is_valid() # старт валидации
    errors # второй вариант, как можно запустить валидацию
        full_clean()
            _clean_fields() # запуск цикла с перебором каждого поля формы
                # у каждого поля
                clean() # всегда
                    to_python() # всегда
                    validate() # всегда
                    run_validators() # если передавали что-то в validators
                clean_<название_поля>() # если самостоятельно описали этот метод внутри класса формы
           _сlean_form()
                _clean() # описываем самостоятельно внутри класса формы, если требуется проверка с участием значений из нескольких полей

Остался ещё один рубеж валидации в форме — метод _post_clean(). Он запускается только в модельных формах (формах-наследниках forms.ModelForm).

7.3.1. Как отрендерить на странице в браузере ошибки, которые вернет метод clean формы?

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

Они записываются в словарь "ошибок вне полей", он доступен через вызов метода non_field_errors() самого объекта формы.

Объект нашей формы находится в переменной space_form, поэтому ошибки от clean джанго-формы отрендерятся в том месте шаблона страницы, где мы пропишем {{ space_form.non_field_errors }}

Разместим данные об ошибках от clean в самом начале формы

    <form actions="" method="POST">
      {% csrf_token %}
      {{ space_form.non_field_errors }}
      ...

А теперь попробуем сделать заказ полета на CrewDragon в день, когда мест на корабле уже нет (число заказов == количество мест на корабле).

Страница с формой после проверки на бэкенде отрендерится так

image

7.4. Как работает _post_clean модельной формы

Задача метода _post_clean модельной формы — запустить валидацию на уровне модели, которую обслуживает джанго-форма (модель, указанная в model в Meta).

"Проверка на уровне модели" означает, что в полях модели и на уровне всей модели тоже могут быть различные проверочные механизмы (валидаторы, ограничения (constraints)) и вот _post_clean как раз и запускает эти механизмы (но есть исключения, о них тоже скажу).

Как _post_clean модельной формы запускает валидацию в модели? Очень просто. У любой модели есть метод full_clean (не путать с full_clean формы).

Обратимся к документации:

Этот метод вызывает Model.clean_fields(), Model.clean(), и Model.validate_unique()(если ``validate_unique() равно True) в указанном порядке и вызывает исключение ValidationError, которое содержит атрибут message_dict с ошибками всех трех этапов проверки.

Метод clean_fields модели действует ровно также как одноименный метод джанго-формы: берет значение для поля и прогоняет его через три проверки — to_python, validate и run_validators (см. исходный код).

Метод clean модели нужно описать самостоятельно, если требуются дополнительные проверки с участием значений из нескольких полей.

Вот что говорит документация:

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

Метод validate_unique модели проверит все ограничения по уникальности для валидируемых полей (unique=True внутри поля, unique_together в Meta, UniqueConstraint в Meta. Для UniqueConstraint, правда, есть исключение, о нем ниже). Повторюсь: проверка будет касаться только полей, которые указаны в модельной форме.

Вернемся к коду нашей модельной формы и увидим, что для flight_date сейчас в форме нет валидатора, который мы прикручивали в "обычной" (до превращения в модельную) форме. Этот валидатор должен проверять, что выбранная дата не меньше чем на 4 недели позже текущей даты.

class OrderForm(forms.ModelForm):
    currency = forms.ChoiceField(
        choices = (
            ('RUB', 'Российский рубль'),
            ('USD', 'Доллар США')
        ),
        label='Валюта платежа'
    )

    class Meta:
        model = Order
        # поле `flight_date` модельное, вне `Meta` мы его не описывали, а внутри `Meta` валидаторы задать нельзя
        fields = ['ship', 'flight_date', 'currency']
        widgets = {
            'ship': RadioSelect, 
            'flight_date': DateInput(attrs={'type': 'date','min': get_min_date})
        }
        labels = {'ship': 'На чем летим', 'flight_date': 'Когда летим'}

У нас есть два варианта (причем они не исключают друг друга):

  1. Определить поле формы flight_date явно вне Meta и передать ему validators=[MinValueValidator(get_min_date)]
  2. Точно такой же валидатор прикрутить к полю flight_date в модели Order (модели, которую обслуживает наша форма).

Не будем менять код формы и задействуем второй вариант.

# spaceflights/models.py
...
def get_min_date():
    """Возвращает текущую дату, увеличенную на 4 недели."""
    return datetime.date.today() + datetime.timedelta(weeks=4)

class Order(models.Model):
    ...
    flight_date = models.DateField(
        # перенесли валидатор из формы в модель
        validators=[
            MinValueValidator(
                get_min_date,
                message=f"Дата должна быть не меньше {get_min_date().strftime('%d-%m-%Y')}"
            )
        ],
        verbose_name='дата полета',
    )
    ... 

Давайте разберемся, как будет происходить валидация выбранной пользователем даты полета через модельную форму:

  1. в словаре request.POST на бэкенд приходит строка с датой по ключу flight_date
  2. после передаче этого словаря через параметр data в форму и вызова .is_valid() джанго ищет среди полей формы поле с именем flight_date
  3. в поле формы flight_date (которое относится к классу DateField) строка с датой преобразуется в объект datetime.date
  4. после валидации остальных значений из request.POST джанго через метод clean() формы проверяет, есть ли на выбранную дату места на выбранном корабле
  5. если валидация методом clean формы прошла успешна, джанго идет в поле flight_date модели Order и запускает теперь уже проверки на его уровне
  6. в поле модели flight_date есть валидатор MinValueValidator(get_min_date), поступившее значение проходит проверку этим валидатором.

7.4.1. form.instance — особенность работы _post_clean

Во время работы _post_clean (который, как мы помним, актуален только для модельных форм) идёт постепенное создание инстанса (instance) объекта записи, который в последующем можно записывать в базу через form.save.

Для этого в классе BaseModelForm есть даже специальный метод construct_instance.

Вот как суть этого метода описана в исходном коде

Construct and return a model instance from the bound form's cleaned_data, but do not save the returned instance to the database.

Выглядит это так:

  1. Допустим, форма обслуживает модель Order
  2. Сначала form.instance = Order() — т.е. пустой объект записи, никакие поля не заполнены
  3. После валидации значения для поля ship модели происходит form.instance.ship = <проверенное_значение_из_поля_ship_модельной_формы>
  4. После валидации значения для поля flight_date модели происходит form.instance.flight_date = <проверенное_значение_из_поля_flight_date_модельной_формы>

Повторюсь: form.instance — это подготовленный, но еще не сохраненный в базу объект записи. В этом объекте вполне может не хватать данных для какого-либо поля (потому что они, по самым разным причинам, не поступали через форму).

Работа с form.instance в контроллере ("вьюхе") позволяет дополнить объект записи новыми данными, чтобы к моменту сохранения объекта записи в БД в нем были все необходимые данные.

Важный момент: в обычных (не модельных) формах атрибута instance нет.

7.4.2. Картина всей последовательности валидации с учетом _post_clean

is_valid() # старт валидации
    errors # второй вариант, как можно запустить валидацию
        full_clean()
            _clean_fields() # запуск цикла с перебором каждого поля формы
                # у каждого поля
                clean() # всегда
                    to_python() # всегда
                    validate() # всегда
                    run_validators() # если передавали что-то в validators
                clean_<название_поля>() # если самостоятельно описали этот метод внутри класса формы
           _сlean_form()
                _clean() # описываем самостоятельно внутри класса формы, если требуется проверка с участием значений из нескольких полей
 ===============================Дальше только для модельных форм========================================
           _post_clean()
                full_clean(validate_unique=False) # вызывается `full_clean` объекта модели (модели, НЕ формы)
                    clean_fields() # у каждого валидируемого поля модели вызывается метод `clean`  
                    clean()
                validate_unique() # вызывается проверка ограничителей по уникальности, установленных для объекта модели (при наличии таковых)

7.5. Вопросы и подводные камни, связанные с валидацией в форме

1. Что НЕ проверит валидация данных через модельную форму?

Валидация через модельную форму НЕ проверит соблюдение ограничений в модели (constraints), кроме ограничений уникальности.

Иными словами, если у вас в классе Meta модели объявлен атрибут constraints и в нем помимо (или вместо) UniqueConstraint есть CheckConstraint (например, вы запрещаете указывать одни и те же данные в двух разных полях), CheckConstraint при валидации модельной формы проверяться НЕ будет.

Обратимся к документации:

In general constraints are not checked during full_clean(), and do not raise ValidationErrors. Rather you’ll get a database integrity error on save(). UniqueConstraints without a condition (i.e. non-partial unique constraints) are different in this regard, in that they leverage the existing validate_unique() logic, and thus enable two-stage validation. In addition to IntegrityError on save(), ValidationError is also raised during model validation when the UniqueConstraint is violated.

Ещё раз: In general constraints are not checked during full_clean(), and do not raise ValidationErrors ("По общему правилу ограничения НЕ проверяются при вызове full_clean() и не поднимают ValidationError"). Исключение — UniqueConstraint.

Но и UniqueConstraint будут проверены не все. Вне проверки останутся UniqueConstraint с условием. Пример такого ограничения есть в документации.

Если CheckConstraint будет нарушено (или UniqueConstraint с условием), то при попытке сохранить в базу валидированных данных, вы получите исключение IntegrityError.

Как быть? Есть два варианта:

  1. Предусмотреть обработку IntegrityError (через try - except) при вызове form.save
  2. Предусмотреть валидацию аналогичную CheckConstraint (или UniqueConstraintс условием) в джанго-форме, например, в методе clean.

2. is_valid() вернул True — значит, при вызове form.save в базу всё запишется без проблем?

Как говорится, it depends. Зависит от двух моментов:

  1. Достаточно ли данных, которые поступили и были проверены через джанго-форму, для создания записи в конкретной таблицы. Если не хватает данных для какого-либо поля, это поле обязательно и у него нет дефолтного значения, то конечно сохранение в базу окончится неудачей.
  2. Настроена обработка исключений, которые могут возникнуть из-за проверок, которые не охвачены валидацией (см. предыдущий вопрос).

3. Где лучше размещать валидаторы — в форме или в в модели, стоит ли их дублировать?

Универсального ответа нет.

Нужно определить (даже если форма модельная) — есть ли у поля формы корреспондирующее поле в модели. Например, в нашей OrderForm у поля currency нет корреспондирующего поля в модели.
В таком случае валидатор нужен в поле формы, больше ему отработать негде.

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

В примере с OrderForm мы перенесли валидатор минимальной даты в поле модели. В чем может быть нелогичность этого шага?

Проверки на уровне модели начинаются строго после того, как отработали все проверки на уровне формы.

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

Т.е. сначала мы дергаем базу в clean формы, а только потом, уже в модели, мы проверяем, а дата-то вообще ниже минимальной или нет.

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

Значит, нужно убрать валидатор из поля модели и перенести его в поле формы? Тут тоже надо подумать. Само по себе дублирование валидатора в поле модели и в поле формы ни хорошо, ни плохо.

Нужно решить, а как в вашем проекте данные будут попадать в базу. Только через форму по конкретному url или как-то ещё?

Если вариантов записи в базу несколько (например, не только через конкретную форму, но и через админку или через API), то, конечно, валидаторы в модели нужно оставлять.

Что почитать, освежить в памяти:

@KaterinaSolovyeva
Copy link

Я правильно понимаю, что class FlightForm и class OrderForm - это одно и тоже?

@stasyao
Copy link
Author

stasyao commented Nov 29, 2021

Да, всё верно, исправил сейчас, чтобы было единообразие

@RustamGiz
Copy link

RustamGiz commented Dec 1, 2021

Как устроены метаклассы в Python (изумительная статья на RealPython, правда, на английском)

Появился перевод статьи https://habr.com/ru/company/piter/blog/592127/

@stasyao
Copy link
Author

stasyao commented Dec 1, 2021

@RustamGiz Супер! Спасибо за информацию, обновил ссылку в списке литературы.

@kitahkitah
Copy link

kitahkitah commented Sep 19, 2022

Спасибо большое за такой содержательный разбор форм)

@kitahkitah
Copy link

kitahkitah commented Sep 20, 2022

Часть 4. Шаг 5.

Полный код формы теперь такой:
class OrderForm(forms.ModelForm)

По логике ты создавал просто форму (о чём, кстати, позже сказал в 6 шаге), исправь на forms.Form

@vedruss-sibir
Copy link

Благодарю, очень содержательно. Хотелось бы и views разобрать.

@Stan001x
Copy link

Спасибо. Отличный разбор форм Джанго.
Если у Вас будет возможность, прошу дополнить статью и разобрать formset, modelformset_factory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment