Работаем с джанго-формами. Часть 1
Работаем с джанго-формами. Часть 2
Работаем с джанго-формами. Часть 3
Работаем с джанго-формами. Часть 4
Работаем с джанго-формами. Часть 5
Привет!
Чтобы лучше усвоить работу с джанго-формами, реализуем небольшой проект.
Легенда такая. Вы — начинающий бэкендер, которого подключили к проекту создания сайта для заказа космических путешествий.
Часть бэка уже написана, есть фронт главной страницы. Как раз на этой стадии подключаетесь вы.
Вместе пройдём по выполнению этой задачи, будем проговаривать каждую деталь, к счастью, спешить некуда.
Важный момент 1: материал устроен так, что ничего (по крайней мере сейчас, у себя на машине запускать не нужно). Весь необходимый код я опишу здесь же. В идеале, всё должно быть понятно из текста.
Если нет, пожалуйста, пишите в комментариях вопросы/пожелания/замечания, буду дорабатывать. Если всё пойдёт по плану, то получится прекрасное подспорье для будущих бэкендеров, а мечта именно такая.
Важный момент 2: этот мини-проект я делаю, по сути, сейчас вместе с вами, поэтому полной заготовки на гитхабе нет. По мере прохождения и обсуждения проекта будет расти и код проекта в репозитории.
Важный момент 3: проект сугубо учебный. Он вряд ли ощутимо пополнит ваше портфолио для потенциальных работодателей, но за то ощутимо прокачает в понимании джанго и максимально осознанном (это главное!) применении инструментов фреймворка.
Поехали!
Пока все очень скромно:
- клиент открывает главную страницу
- оттуда переходит на страницу с формой заказа. Там он должен выбрать:
- корабль, на котором хочет полететь
- дату полета (не раньше чем через месяц после текущей даты)
- выбрать валюту платежа (пока доступны только доллары США и российские рубли)
- после выбора всех указанных опций он кликает на кнопку "Оформить". Важная деталь: если все места на выбранный корабль и выбранную дату исчерпаны, должно появляться информация об этом с просьбой перезаполнить форму.
- если всё прошло штатно, то клиенту должна открыться следующая страница для завершения оформления заказа:
- должна отображаться стоимость полета, исходя из ранее введененных данных.
Сейчас стоимость определена в долларах, поэтому если клиент выбрал в качестве валюты рубли, надо конвертировать стоимость в рубли и показать ее по курсу на сегодня (разумеется, с сайта ЦБ).
Но в базу все равно должно сохраниться значение в долларах. - введет свои имя и фамилию
- адрес электронной почты
- нажимает на кнопку "Поехали!", после чего заказ сохраняется в базе данных.
- должна отображаться стоимость полета, исходя из ранее введененных данных.
- описаны модели
- подключена админка
- база заполнена данными о кораблях
- открывается главная страница, в ссылке на страницу перехода к форме оформления заказа пока стоит заглушка.
Нужна вот такая форма для получения данных
Необходимые для формы данные нужно получать из БД (кроме валюты платежа).
Шаг 1. Создать страницу, на которой будет располагатся форма выбора корабля, даты полета и валюты платежа
В конце концов, мы же где-то должны показать эту форму :)
Что нужно:
- решить, куда положить файл с новой страницей
- создать html-файл с минимально необходимой разметкой
- на странице сделать заготовку для формы
Джанго ищет шаблоны страниц не рандомно, а во вполне конкретных местах.
Что это за места оговоривают в настройках проекта — 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
.
Теперь нужно описать минимум разметки — теги 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
, то, что в нем, пользователь увидит на странице в браузере.
Скелет любой html
-формы — это тег form
.
Создадим его внутри body
.
<body>
<form>
</form>
</body>
Теперь нужно ответить на три вопроса:
- После сабмита (проще говоря, нажатия кнопки типа "Отправить"), какой контроллер ("вьюха", "вью-функция") будет приводится в действие (работать с данными из формы и т.д.)?
- Какой
url
обслуживает этот контроллер (потому что задействовать мы его можем не иначе, как через обращение по урлу)? - Каким http-методом —
GET
илиPOST
— будут отправляться данные из формы?
Ответы на первые два вопроса нам нужны для описания атрибута actions
в теге form
. Атрибут actions
, конечно, можно опустить, но тогда будет задействован тот же контроллер, что и отрендерил текущую страницу с формой.
Нам это не подходит, ибо ТЗ звучит так
если всё прошло штатно, то клиенту должна открыться следующая страница для завершения оформления заказа
Должен работать следующий контроллер, который будет брать данные из текущей формы и оперировать с ними в следующей форме, которая откроется на следующей странице.
Но пока такого контроллера нет (а, значит, и маршрута). Чтобы вообще не забыть об actions
укажем actions=""
, потом допишем новый маршрут, когда сделаем новый контроллер.
Что касается третьего вопроса, то данные из формы будем передавать POST
-запросом.
Итог
<body>
<!-- дописать action, когда появится контроллер следующей формы -->
<form action="" method="POST">
</form>
</body>
Собственно говоря, для этого у нас всё есть (точнее одно-единственное — шаблон страницы, который мы положили в правильное место).
Как я уже говорил, вся логика проекта в одном приложении, spaceflights
. Идём в views.py
оттуда и создаём там контроллер.
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
мы передаем шаблон, который был предварительно превращен в строку загрузчиком шаблонов. И с этой строкой дальше работает браузер.
Чтобы заработал контроллер и что-нибудь вернул, к нему нужно обратиться по специально для этого контроллера предусмотренному адресу.
Т.е. складывается следующая цепочка: 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/">
Итого: подготовительный этап завершен. С главной страницы мы попадаем на отдельную страницу, которая пока пуста, но на ней есть заготовка формы.
Сейчас на html
-страницы наша форма обозначена так:
<!-- дописать action, когда появится контроллер следующей формы -->
<form action="" method="POST">
</form>
Внутри формы нужно создать поля (теги input
) разных типов (type=...
), которые будут принимать данные.
Прежде чем писать html-разметку для полей самостоятельно, максимально задействуем возможности джанго. Будем управлять разметкой формы с бэкенда.
Начальный шаг для создания джанго-формы (задача которой: а) взять на себя создание части разметки на странице, б) проверить поступившие из html-формы даннные), всегда один и тот же: нужно создать python-класс у нашей формы и унаследоваться от класс джанго-форм.
Принято формы размещать отдельно от контроллеров, поэтому создадим внутри приложения spaceflights
новый файл forms.py
Объявим класс джанго-формы, предварительно сделав необходимый импорт:
from django import forms
class OrderForm(forms.Form):
...
...
это Python-объект ellipsis
, который можно использовать аналогично заглушке pass
(для самых любопытных короткая статья).
Круто, класс формы мы объявили, пока ни одного атрибута (поля) в форме нет.
Приступим к их созданию, руководствуясь ТЗ.
Нам нужно ответить, как минимум, на следующие вопросы (и так для каждого поля создаваемой нами Django-формы):
- Для какого поля в какой модели предназначены данные из конкретного поля
html
-формы? - Как должно называться джанго-поле при рендеринге на
html
-страницe? - Какой класс джанго-формы наиболее подходит для того, чтобы отрендерить поле в
html
-форме и проверить поступившие через него данные? - Подходит ли нам дефолтный виджет (иначе говоря `<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
. А селекты и радиокнопки, как говорят в Одессе, это две большие разницы.
Заглянем в классы виджетов, которые доступны из коробки.
Название нужного нам виджета говорит само за себя: 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
).
Уже ощутимый результат. Теперь нам нужно избавиться от черных маркеров (а может и вообще от тегов списка), а еще рядом с радиокнопками выводить релевантные картинки (те, которые привязаны в базе к конкретному кораблю). Этим мы займемся в следующий раз.
НЕ обязательный раздел, можно почитать, если есть время. Цель — посмотреть, как устроено создание форм под капотом.
Сейчас код формы такой:
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',]
Вспоминаем основы ООП:
- Всё в Python — объекты.
- Классы сами по себе — объекты.
Привыкаешь всегда смотреть на классы только в формате класс()
, т.е. когда объект класса вызывается и порождает другой объект — экземпляр класса.
Но давайте не будем создавать пока экземпляр класса нашей формы. А посмотрим, как создается сам класс формы.
За создание любого объекта в Python
, в том числе класса, отвечает метод __new__
. Чтобы переопределить поведение __new__
именно для классов-как-объектов, используют метаклассы. Собственно, это мы и видели в сигнатуре класса forms.Form
— на второй позиции указан метакласс: metaclass=DeclarativeFieldsMetaclass
.
Из описания метода __new__
этого метакласса мы и поймем, что делает джанго с атрибутами класса формы при создании этого класса (повторюсь — самого класса, не его экземпляра).
- 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 по мск продолжим обсуждать джанго-формы на вебинаре.
Что почитать, освежить (но ни в коем случае не застревать, если что-то будет непонятно)
- html-тег
form
- Атрибуты html-тега
input
- Как джанго ищет шаблоны
- Как работает шаблонный тег
url
- Как джанго сопоставляет маршруты с контроллерами
- Атрибут
label
поля джанго-формы - Виджет
RadioSelect
поля джанго-формы - Как устроены метаклассы в Python (русский перевод изумительной статьи с RealPython)
Сейчас на странице наша форма выглядит так
А в шаблоне так
<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
рядом с лейблом поля появится маркер списка
Фактически рендерить всю форму целиком из переменной с ее объектом не очень практично — верстка, которая заложена под капотом, достаточно топорна и вряд ли пригодится дальше чернового варианта страницы.
Для более гибкого управления рендерингом формы в шаблоне можно обращаться к каждому полю по отдельности.
Есть два варианта.
Вариант 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
.
Когда мы объявлем поле формы, например, так
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>
На странице мы увидели значение атрибута required
— True
. Поле обязательно.
Вот один из многих вариантов того, как это может пригодится на практике — по-разному стилизовать обязательные и не обязательные поля при рендеринге формы.
Отмечу, что 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>
Обратимся в шаблоне к полю напрямую.
<form actions="" method="POST">
{% csrf_token %}
{{ space_form.ship }}
</form>
Что поменялось? В верстке исчезла обертка в виде табличных тегов (которые раньше давал form.as_table()
), но это незаметно.
Зато видно, что исчез лейбл поля, нет На чем летим
.
И здесь нужно запомнить важное правило: при непосредственном управлении полем в разметке нужно учитывать, что оно не монолитно. Оно состоит из целого ряда элементов: само поле (например, поле для ввода текста), лейбл поля, вспомогательный текст (help text), текст ошибки, которые вернет бэкенд после проверки поля и т.д.
Всё это нужно самостоятельно указывать в шаблоне, используя атрибуты BoundField`.
У BoundField
есть атрибут label
,
через него мы получаем доступ к лэйблу поля. Укажем лэйбл в шаблоне
<form actions="" method="POST">
{% csrf_token %}
{{ space_form.ship.label }} <!-- label, один из атрибутов BoundField -->
{{ space_form.ship }}
</form>
Лэйбл вернулся.
Почему никуда не исчезали названия радиокнопок? Это особенность виджета RadioSelect
и других виджетов, предполагающих выбор из заранее отрендеренных значений (чекбоксы, селекты).
За рендеринг каждой отдельной радиокнопки отвечает "подвиджет" (subwidget
) и в качестве лейблов они используют названия объектов из переданного при создании поля набора записей.
Иными словами, через BoundField.label
мы управляем лейблом всего набора радиокнопок (label
всего поля ship), а не названиями каждой радиокнопки в отдельности.
Можно ли управлять отдельными виджетами (собственно говоря, каждым конкретным инпутом-радиокнопкой)? Да, можно.
Дело в том, что объект класса BoundField
(а именно с ними мы работаем в шаблоне), итерабельны, т.е. по ним можно пройтись циклом.
Но что именно будет перебирать цикл? Смотри на реализацию метода __iter__
у BoundField
def __iter__(self):
return iter(self.subwidgets)
Перебираться будут как раз сабвиджеты, поэтому запускать цикл есть смысл только тогда, когда поле использует мультивиджет (радиокнопки, чекбоксы и т.п.).
Каждый сабвиджет (в нашем случае каждая радиокнопка) относится к классу BoundWidget
и содержит, в частности, такие атрибуты и методы:
tag
— сама кнопка (чек-бокс и т.п.), без подписиchoice_label
— подпись к кнопке (чек-боксу и т.п.)id_for_label
— айдишник радиокнопки (в частности, нужен, чтобы связать надпись с конкретной кнопкой)
Теперь мы можем полностью избавиться от дефолтной обертки радиокнопок тегами li
(а значит, назойливыми черными буллитами списка), и обернуть кнопки и подписи к ним во что захотим.
Попробуем такой пример
<form actions="" method="POST">
{% csrf_token %}
<!-- название всего поля (всей группы радиокнопок)-->
{{ space_form.ship.label }}
<!-- запускаем цикл по полю == перебираем все радиокнопки -->
{% for radiobutton in space_form.ship %}
{{ radiobutton }}
{% endfor %}
</form>
Исчезли черные кружки возле каждой радиокнопки, а сами кнопки расположились не в столбик, а в ряд. О чем это говорит?
О том, что дефолтная обертка радиокнопок в теги 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
мы также добавили айдишник радиокнопки, чтобы связать подпись и кнопку - отобразили подпись.
Смысл тега лейбл с айдишником лишь в том, чтобы пользователь мог кликать не только в саму кнопку, чтобы ее выбрать, но и по тексту рядом с ней.
Продолжение выложу здесь в пятницу, 3 декабря, а в среду, 1 декабря в 19.00 по мск продолжим обсуждать джанго-формы на вебинаре.
Что почитать (освежить в памяти)
- Протокол итерации в Python
- Атрибуты объектов класса
BoundField
- Шаблонный тег for
- Шаблонный тег if
- Цикл по полям джанго-формы, полезные атрибуты
- От чего защищают csrf-токены
- Деление html-элементов на строчные и блочные
- html-тег input type="radio"
- html-тег label
Нам нужно, чтобы рядом с каждой радиокнопкой выбора корабля появлялась картинка, привязанная к записе об этом корабле.
Что сейчас из себя представляет поле 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/.
У нас три записи: 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
)
Результат предсказуем: в верстке появится только одна радиокнопка, т.к. кверисет всего из одной записи
В модели 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
, я очень подробно писал в первой части пособия).
В таблице Spaceship
в базе данных в свою очередь хранятся адреса привязанных к записям картинок.
Для того, чтобы отобразить картинку в html-странице, нам как раз нужен ее адрес. Его мы укажем в атрибуте src
тега img
.
Добраться до адреса картинки в базе можно так: объект_записи.название_поля_для_картинок.url
.
Если, к примеру, в переменной spaceship_obj
будет запись из таблицы Spaceship
, то к адресу картинки, привязанной к этой записи,
мы доберемся так: spaceship_obj.image.url
.
Итак, мы поняли как добраться к адресу картинки для каждой записи. Для этого нужна... да, сама запись.
Заглянем в шаблон, еще раз посмотрим, как на странице появляются радиокнопки:
{% 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
у нас нет.
Если сфокусироваться на текущем коде шаблона страницы, то становится понятным, что мы:
а) должны оставить имеющийся цикл, на каждой итерации которого появляется кнопка и подпись к ней
б) должны дополнить цикл еще одной переменной, за которой будет стоять объект записи из кверисета, переданного в 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
}
)
Что мы сделали:
- Взяли набор записей, для этого мы обратились к полю
ship
черезform.fields['ship']
и дальше взяли значение атрибутаqueryset
. Это тот самыйqueryset
, который передавался полю в джанго-форме - Тем самым мы гарантировали, что мы взяли именно тот набор записей, на основе которого рендерятся радиокнопки, и именно в том порядке, в котором они будут расположены
- С помощью
list comprehension
мы прошлись по набору, взяли из каждой записи адрес картинки и составили список из этих адресов - Создали
zip
-итератор:- на первом месте поле (внимание - в данном случае мы взяли поле как объект класса
BoundField
, только он итерабелен) - за полем скрываются три радиокнопки (по числу записей в кверисете)
- на втором месте список адресов картинок (и их, разумеется тоже три, т.к. они из того же набора записей)
- поскольку источником обоих списков является один и тот же набор записей, мы уверены в том, что каждая пара "радиокнопка-картинка" будет правильной.
- на первом месте поле (внимание - в данном случае мы взяли поле как объект класса
- Передали итератор в контекст шаблона (переменная
ship_field
).
Теперь мы запустим цикл в шаблоне не по полю 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
), мы еще задали стилевые свойства, чтобы зафиксировать ширину каждой картинки, а высоту браузер подберет сам, чтобы сохранялись пропорции картинки.
Результат 🔥
К полю согласно ТЗ три требования:
- оно должно называться "Когда летим"
- выбор даты осуществляется с помощью календаря
- должны быть доступны не раньше +1 месяц от текущей даты (тут мы сейчас немного упростим и заменим условие на +4 недели, так проще будет дельту времени задать)
В первой части пособия мы подробно рассматривали алгоритм подбора поля формы, которое будет получать данные для корреспондирующего поля модели.
В данном случае мы выбираем поле forms.DateField
. Начальный код поля в джанго-форме будет выглядеть так:
flight_date = forms.DateField()
Даже не заглядывая в верстку, мы знаем, как это поле будет отображено
Дефолтным лейблом при этом будет Flight date
.
Поменять лэйбл просто, надо явно передать нужное значение полю в параметре label
: flight_date = forms.DateField(label='Когда летим')
Разобраться с лейблом было легко, но есть куда более серьезная проблема. Виджет 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
виджета поля джанго-формы.
- Пробираемся к виджету. Для этого обращаемся к параметру
widget
поля и явно указываем в нем объект виджета:
flight_date = forms.DateField(
label='Когда летим',
widget=DateInput()
)
- Теперь в параметрах виджета нужно указать
attrs
и передать этому параметру словарь с нужными нам атрибутами (парами "ключ"-"значение", которые мы хотим видеть внутри<input>
).
flight_date = forms.DateField(
label='Когда летим',
widget=DateInput(
'attrs': {'type': 'date'}
)
)
Вот. Теперь у нас будет рендерится <input type="date"...>
. Проверим
Ура! Календарь готов.
Прежде чем перейти к решению задачи на конкретном поле, посмотрим на вопрос валидации в масштабе.
Самое главное: четко различать уровни валидации.
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
.
В поле джанго-формы можно установить и валидацию на бэкенде, и валидацию на фронтенде. Начнем с первой.
Чтобы установить валидатор на бэкенде, нужно указать его в параметре 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 с поясненим к полю. Написать рядом с полем, какая дата будет считаться правильной.
Но есть вариант еще более удобный для пользователя — сразу скрыть из календаря даты, которые он не может выбрать. За это отвечает валидатор на фронте.
Мы помним: чтобы сделать валидацию на фронте, нужно указать валидатор в 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"
.
Что почитать, освежить в памяти:
- 9 уровней применения функции zip
- list comprehensions за 5 минут
- html-тег img
- Поле модели ImageField
- Свойство url полей модели для загрузки файлов
- Поле джанго-формы
DateField
- Виджет
DateInput
- input type="date"
- form.errors
- form.is_valid()
- field.validators
- MinValueValidator
- datetime.date.today
- datetime.timedelta
- Почему str к объекту даты возвращает строку именно в формате ISO 8601
Полезные инструменты:
Мы уже достаточно набили руку в создании полей джанго-формы, поэтому сразу в бой:
-
Назовем поле
currency
. -
Класс поля джанго-формы —
ChoiceField
. НеModelChoiceField
, а именноChoiceField
, т.к. корреспондирующего поля в модели для данного поля формы нет.
Иными словами, в модели нет такого поля, данными из которых мы бы нагенерировали опции выбора в форме (как мы это сделали для поля выбора корабля). -
Из описания поля
ChoiceField
в документации мы видим, что его стандартный виджетSelect
. А стандартный виджетSelect
, в свою очередь, рендерится в в виде выпадающего списка опций.
<select>
<option ...>...
</select>
В рамках нашего ТЗ дефолтный виджет нас полностью устраивает, ничего переопределять не будем.
-
Поле
ChoiceField
при создании принимает аргумент для параметраchoices
. В качестве аргумента передается список или кортеж с опциями, которые будет выбирать пользователь. -
Каждая опция представлена в виде кортежа из двух элементов. Первый — что будет приходить на бэкенд, второй — что будет видеть пользователь в форме,
Для нашей ситуации choices
будет таким
(
('RUB', 'Российский рубль'),
('USD', 'Доллар США')
)
В выпадающем списке на странице в браузере пользователь будет видеть Российский рубль
и Доллар США
, а на бэкенд в составе POST-запроса будет приходить либо пара
"currency": "RUB"
, либо пара "currency": "USD"
.
- Согласно ТЗ в форме на странице поле должно называться "Валюта платежа", поэтому передадим соответствующую строку для параметра
label
поля.
В итоге код поля в джанго-форме будет таким
currency = forms.ChoiceField(
choices=(
('RUB', 'Российский рубль'),
('USD', 'Доллар США')
),
label='Валюта платежа'
)
Добавим его в шаблон html-страницы
<div style="margin-top: 30px;">
{{ space_form.currency.label }}
{{ space_form.currency }}
</div>
Отрендерится поля вот так:
Полный код формы теперь такой:
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% понимая, что вы хотите сказать или что хотел сказать тот, чей код вы читаете (а на практике читать чужой код придется куда чаще, чем писать собственный).
До текущего момента мы создавали форму на основе джанго класса Form
, наследника BaseForm
.
У BaseForm
есть еще один класс-наследник — класс ModelForm
.
Он полезен, когда поля формы используются для получения данных, на основе которых будет формироваться запись для БД.
Иными словами, когда можно выстроить цепочку поле формы -> поле модели
.
У нас как раз такой случай — 2 из 3 полей формы можно сопоставить с полями модели Order
:
- поле джанго-формы
ship
-> полеship
моделиOrder
- поле джанго-формы
flight_date
-> полеflight_date
моделиOrder
.
Создание формы на базе класса ModelForm
позволяет нам доверить создание полей формы самому джанго.
В минимальном варианте требуется следующее:
- прописать внутри класса формы класс
Meta
- в классе
Meta
два атрибута:model
— модель, которую обслуживает джанго-формаfields
— список (или кортеж) полей модели (важно: именно модели, не формы), для которых джанго автоматически создаст поля в нашей форме.
Вот что представляет из себя модельная форма, если бы мы описали поля самостоятельно через "обычную" форму.
Модельная форма "Обычная" форма
-------------------------------------------|----------------------------------------
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())
джанго делает за нас.
Разберем, как он это делает:
- Джанго идёт в модель
Order
и смотрит, какой класс у поляship
этой модели. Видит, что классForeignKey
- Джанго смотрит "карту" соответствия классов полей модели классам полей формы. Видит, что классу поля модели
ForeignKey
соответствует класс поля формыModelChoiceField
. - Поскольку поле
ship
моделиOrder
через внешний ключ связано с модельюSpaceship
, то атрибутомqueryset
дляModelChoiceField
становитсяSpaceship.objects.all()
. - Также джанго видит, что у поля
ship
естьverbose_name='корабль'
, т.е. человекочитаемое название, его он берет в качестве лейбла для поля формы. - В качестве названия самого поля джанго берет то же название, что и поле модели, т.е.
ship
.
Так и рождается поле джанго-формы ship = forms.ModelChoiceField(label='Корабль', queryset=Spaceship.objects.all())
.
С полем flight_date
происходят точно такие же манипуляции.
Создание модельной формы (формы-наследника класса 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
? Тут возможны два сценария:
- Поле
currency
явно объявлено внеMeta
(так, как сейчас). Тогда ничего не произойдёт, джанго проигнорирует наличиеcurrency
вfields
. - Поле
currency
не объявлено явно. Тогда джанго пойдёт в модельOrder
искать полеcurrency
, не найдёт его там и вы увидите ошибку
django.core.exceptions.FieldError: Unknown field(s) (currency) specified for Order
Отсюда вывод: указывать в fields
названия полей, которых нет в модели, как минимум, бессмысленно, а как максимум всё сломает.
Да-да, доверив создание полей ship
и flight_date
полностью джанго мы сталкиваемся с теми же проблемами, которые решали, когда объявляли поля самостоятельно:
- Не устраивает дефолтный виджет
select
для поляModelChoiceField
, нужны радиокнопки, а не выпадающий список опций. - Поле для даты опять не календарик, а окошко для ввода даты текстом, плюс никаких валидаторов на фронте
- Дефолтные подписи к полям не подходят.
Иными словами, джанго создал
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
.
В чем смысл?
- Причина первая, банальная — без полей в
fields
мы вообще не можем создать модельную форму. Но зачем тогда использоватьModelForm
, если можно создать "обычную" форму (наследникаforms.Form
)? - Автоматическое создание полей формы — не единственная фишка
ModelForm
. Есть ещё как минимум две очень важных вещи:
- при валидации модельной формы запускаются проверки не только на уровне формы, но и на уровне модели. Если мы создадим поле
ship
в обычной форме, то, разумеется, валидаторы, которые есть для одноименного поля в модели запускаться не будут (потому что связки "модель - форма" нет). В модельной же форме будут (еслиship
будет указано вfields
внутриMeta
). - в модельной форме есть методы
save
иsave_m2m
, возможности которых значительно упрощают создание записи в БД на основе данных из полей модельной формы.
Для поля формы currency
всё остаётся так, как было написано выше, — поскольку корреспондирующего поля модели для него нет, то и в fields
его указывать бессмысленно.
Какой вариант переопределения атрибутов полей модельной формы выбрать? Ответ прост — если вам нужно переопределить что-то, для чего предусмотрены опции в Meta
(а именно виджет, вспомогательный текст, лейбл, класс поля, сообщения об ошибках), то воспользуйтесь вариантом 1. Если же опций в Meta
не хватает (например, в Meta
нет validators
), тогда переопределяйте поля из fields
явно, т.е. описывая их вне Meta
.
Что почитать, освежить в памяти:
- класс поля джанго-формы
ChoiceField
- виджет джанго-формы
Select
- как джанго подбирает класс поля формы для конкретного класса поля модели
- как переопределить атрибуты поля модельной формы и его класс
Независимо от того, какую форму мы создаём — "обычную" (наследника forms.Form
) или модельную (наследника forms.ModelForm
) — валидация на уровне формы устроена одинаково. Фишка модельной формы в том, что после валидации на уровне формы она подключает валидацию на уровне модели.
Чтобы джанго-форма начала процедуру валидации нужны две вещи:
- Данные, которые нужно валидировать. Они передаются в параметре
data
при создании объекта формы.
В качестве данных для проверки мы берем данные, которые пришли с фронтенда в словареrequest.POST
.
order_form = OrderForm(data=request.POST)
- Нужно запустить валидацию. Для этого нужен экземпляр джанго-формы с переданными ему данными для проверки и вызов у него одного из двух методов (атрибутов):
.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()
В этом коде сердце валидации и мы можем понять ее фундамент:
- Создаются два словаря — словарь для сбора данных об ошибках (
self._errors
) и словарь для сбора проверенных, не вызвавших ошибок, данных (self.cleaned_data
). - Одна за другой следуют три волны проверок:
- методом
_clean_fields()
- методом
_clean_form()
- методом
_post_clean()
Видим, что метод full_clean
формы ничего не возвращает. Его задача запустить методы различных проверок и через них наполнить (если есть чем) словари ошибок и проверенных данных.
Если очень коротко, то суть метода _clean_fields
в том, чтобы запустить цикл по всем полям формы.
На каждой итерации у конкретного поля вызывается метод 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'
.
Задача этого метода в каждом поле — нормализовать поступившую строку с данными, привести ее к какому-либо 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)
.
Метода 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
, переопределять это поведение вам вряд ли понадобится.
Этот метод запускает валидаторы, которые мы передали через параметр validators
. Вспомним, как объявляли поле flight_date
формы.
flight_date = forms.DateField(
label='Когда летим',
validators=[MinValueValidator(get_min_date)], # валидаторы отсюда запускает `run_validators`
widget=...
Сам метод run_validators
трогать не нужно, просто следует знать, что валидаторы можно передать списком через validators
.
Теперь мы можем описать весь путь строки '2022-01-31'
при обработке методом clean
поля:
to_python
превращает вdatetime.date(2022, 1, 31)
validate
пропускает дальше, т.к. для обязательного пришли данныеrun_validators
запускаетMinValueValidator(get_min_date)
, которые тоже пропускает объект даты, т.к. она не меньше минимальной
Джанго ищет, а есть ли в классе нашей формы метод, название которого соответствовало бы формату 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()
формы.
После того, как провалидировано значение для каждого поля в отдельности, запускается метод _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
Логика метода:
- Запустить метод
clean
формы - Если этот метод поднимет исключение
ValidationError
, то добавить это исключение в словарь с ошибками - Если метод отработает без ошибок, то есть два варианта:
- ничего не делать, если успешно отработавший
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
).
По умолчанию ошибки, которые возвращаются после отработки метода 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 в день, когда мест на корабле уже нет (число заказов == количество мест на корабле).
Страница с формой после проверки на бэкенде отрендерится так
Задача метода _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': 'Когда летим'}
У нас есть два варианта (причем они не исключают друг друга):
- Определить поле формы
flight_date
явно внеMeta
и передать емуvalidators=[MinValueValidator(get_min_date)]
- Точно такой же валидатор прикрутить к полю
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='дата полета',
)
...
Давайте разберемся, как будет происходить валидация выбранной пользователем даты полета через модельную форму:
- в словаре
request.POST
на бэкенд приходит строка с датой по ключуflight_date
- после передаче этого словаря через параметр
data
в форму и вызова.is_valid()
джанго ищет среди полей формы поле с именемflight_date
- в поле формы
flight_date
(которое относится к классуDateField
) строка с датой преобразуется в объектdatetime.date
- после валидации остальных значений из
request.POST
джанго через методclean()
формы проверяет, есть ли на выбранную дату места на выбранном корабле - если валидация методом
clean
формы прошла успешна, джанго идет в полеflight_date
моделиOrder
и запускает теперь уже проверки на его уровне - в поле модели
flight_date
есть валидаторMinValueValidator(get_min_date)
, поступившее значение проходит проверку этим валидатором.
Во время работы _post_clean
(который, как мы помним, актуален только для модельных форм) идёт постепенное создание инстанса (instance
) объекта записи, который в последующем можно записывать в базу через form.save
.
Для этого в классе BaseModelForm
есть даже специальный метод construct_instance
.
Вот как суть этого метода описана в исходном коде
Construct and return a model instance from the bound
form
'scleaned_data
, but do not save the returned instance to the database.
Выглядит это так:
- Допустим, форма обслуживает модель
Order
- Сначала
form.instance = Order()
— т.е. пустой объект записи, никакие поля не заполнены - После валидации значения для поля
ship
модели происходитform.instance.ship = <проверенное_значение_из_поля_ship_модельной_формы>
- После валидации значения для поля
flight_date
модели происходитform.instance.flight_date = <проверенное_значение_из_поля_flight_date_модельной_формы>
Повторюсь: form.instance
— это подготовленный, но еще не сохраненный в базу объект записи. В этом объекте вполне может не хватать данных для какого-либо поля (потому что они, по самым разным причинам, не поступали через форму).
Работа с form.instance
в контроллере ("вьюхе") позволяет дополнить объект записи новыми данными, чтобы к моменту сохранения объекта записи в БД в нем были все необходимые данные.
Важный момент: в обычных (не модельных) формах атрибута instance
нет.
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() # вызывается проверка ограничителей по уникальности, установленных для объекта модели (при наличии таковых)
Валидация через модельную форму НЕ проверит соблюдение ограничений в модели (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
.
Как быть? Есть два варианта:
- Предусмотреть обработку
IntegrityError
(черезtry - except
) при вызовеform.save
- Предусмотреть валидацию аналогичную
CheckConstraint
(илиUniqueConstraint
с условием) в джанго-форме, например, в методеclean
.
Как говорится, it depends. Зависит от двух моментов:
- Достаточно ли данных, которые поступили и были проверены через джанго-форму, для создания записи в конкретной таблицы. Если не хватает данных для какого-либо поля, это поле обязательно и у него нет дефолтного значения, то конечно сохранение в базу окончится неудачей.
- Настроена обработка исключений, которые могут возникнуть из-за проверок, которые не охвачены валидацией (см. предыдущий вопрос).
Универсального ответа нет.
Нужно определить (даже если форма модельная) — есть ли у поля формы корреспондирующее поле в модели. Например, в нашей OrderForm
у поля currency
нет корреспондирующего поля в модели.
В таком случае валидатор нужен в поле формы, больше ему отработать негде.
Если поле формы связано с полем модели, то нужно оценить, как вообще устроена последовательность проверок в конкретной форме.
В примере с OrderForm
мы перенесли валидатор минимальной даты в поле модели. В чем может быть нелогичность этого шага?
Проверки на уровне модели начинаются строго после того, как отработали все проверки на уровне формы.
В нашей форме есть метод clean
, который проверяет наличие мест на выбранном корабле на выбранную дату, а для этого дергает базу (отправляет запрос к ней).
Т.е. сначала мы дергаем базу в clean
формы, а только потом, уже в модели, мы проверяем, а дата-то вообще ниже минимальной или нет.
Получается, мы можем напрасно дергать базу, если изначально дата по критерию минимальности не проходит. Логичнее сначала проверить минимальность даты (через validators
в поле формы), а потом уже в clean
(если всё хорошо) обращаться к базе для вычисления количества заказов на конкретный день.
Значит, нужно убрать валидатор из поля модели и перенести его в поле формы? Тут тоже надо подумать. Само по себе дублирование валидатора в поле модели и в поле формы ни хорошо, ни плохо.
Нужно решить, а как в вашем проекте данные будут попадать в базу. Только через форму по конкретному url
или как-то ещё?
Если вариантов записи в базу несколько (например, не только через конкретную форму, но и через админку или через API), то, конечно, валидаторы в модели нужно оставлять.
Что почитать, освежить в памяти:
- Проверка форм и полей форм
- Валидаторы полей формы
- Проверки в кастомном поле формы
- Как написать проверку методом
clean_названиеПоляФормы
- Как работает метод clean формы
- Метод
non_field_errors
формы - Метод
full_clean
модели - Установка ограничений в модели
- Django’s Field Choices Don’t Constrain Your Data(маленькая, но очень познавательная статья)
Я правильно понимаю, что class FlightForm и class OrderForm - это одно и тоже?