January 22, 2020

Идеальная форма. Обрабатываем сложные формы на Python с помощью WTForms

Источник: t.me/Bureau121

Содержание статьи

  • Зачем это нужно?
  • Установка
  • Создание формы
  • Работа с формой
  • Генерация формы (GET /users/new)
  • Парсинг пейлоада (POST /users)
  • Опции для частичного парсинга пейлоада
  • Валидаторы
  • Динамическое изменение свойств полей формы
  • Сборные и наследуемые формы
  • Заполнение реляционных полей (one-to-many, many-to-many)
  • Кастомные виджеты и расширения
  • Вместо заключения

Зачем это нужно?

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

<form action="">
    <!-- personal info -->
    <input type="text" id="f_name" name="f_name" placeholder="John" />
    <input type="text" id="l_name" name="l_name" placeholder="Dow" />

    <!-- account info -->
    <input type="email" id="email" name="email" placeholder="[email protected]" />
    <input type="password" id="password" name="password" placeholder="**********" />

    <!-- meta info -->
    <select name="gender" id="gender">
        <option value="0">Male</option>
        <option value="1" selected>Female</option>
    </select>
    <input type="city" id="city" name="city" placeholder="Saint-Petersburg" />
    <textarea name="signature" id="signature" cols="30" rows="10"></textarea>

    <input type="submit" value="Create user!" />
</form>

Эта форма выглядит просто. Однако использование в реальном приложении добавит ряд задач.

  1. У каждого поля (или в одном блоке) нужно вывести информацию об ошибках, которые могут появиться при валидации формы.
  2. Скорее всего, для некоторых полей мы захотим иметь подсказки.
  3. Наверняка нам нужно будет повесить по одному или несколько CSS-классов на каждое поле или даже делать это динамически.
  4. Часть полей должна содержать предзаполненные данные с бэкенда — предыдущие попытки сабмита формы или данные для выпадающих списков. Частный случай с полем gender прост, однако опции для селекта могут формироваться запросами к БД.

И так далее. Все эти доделки раздуют нашу форму как минимум вдвое.

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

  1. Корректно смаппить его по name.
  2. Проверить диапазон допустимых значений — валидировать форму.
  3. Если были ошибки, сохранить их, вернув форму для редактирования назад на клиентскую часть.
  4. Если все ОK, то смаппить их на объект БД или аналогичную по свойствам структуру для дальнейшего процессинга.

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

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

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

Было бы удобнее описать форму в каком-то декларативном формате, например в виде Python-класса, одноразово описав все параметры, классы, валидаторы, обработчики, а заодно предусмотрев возможности ее наследования и расширения. Вот тут-то нам и поможет библиотека WTForms.

INFO

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

Установка

Для начала установим саму библиотеку. Я буду показывать примеры на Python 3. Там, где нужен контекст, код исполняется в обработчике фреймворка aiohttp. Сути это не меняет — примеры будут работать с Flask, Sanic или любым другим модулем. В качестве шаблонизатора используется Jinja2. Устанавливаем через pip:

pip install wtforms

Проверяем версию.

import wtforms
wtforms.__version__
# '2.2.1'

Попробуем переписать форму выше на WTForms и обработать ее.

Создание формы

В WTForms есть ряд встроенных классов для описания форм и их полей. Определение формы — это класс, наследуемый от встроенного в библиотеку класса Form. Поля формы описываются атрибутами класса, каждому из которых при создании присваивается инстанс класса поля типа, соответствующего типу поля формы. Звучит сложно, на деле проще.

from wtforms import Form, StringField, TextAreaField, SelectField, validators

class UserForm(Form):
    first_name = StringField('First name', [validators.Length(min=5, max=30)])
    last_name = StringField('Last name', [validators.Length(min=5, max=30)])

    email = StringField('Email', [validators.Email()])
    password = StringField('Password')

    # meta
    gender = SelectField('Gender', coerce=int, choices=[  # cast val as int
        (0, 'Male'),
        (1, 'Female'),
    ])
    city = StringField('City')
    signature = TextAreaField('Your signature', [validators.Length(min=10, max=4096)])

Вот что мы сделали:

  • создали класс UserForm для нашей формы. Он наследован от встроенного FormBaseForm);
  • каждое из полей формы описали атрибутом класса, присвоив объект встроенного в либу класса типа Field.

В большинстве полей формы мы использовали импортированный класс StringField. Как нетрудно догадаться, поле gender требует ввода другого типа — ограниченного набора значений (м/ж), поэтому мы использовали SelectField. Подпись пользователя тоже лучше принимать не в обычном input, а в textarea, поэтому мы использовали TextAreaField, чье HTML-представление (виджет) — тег <textarea>. Если бы нам нужно было обрабатывать числовое значение, мы бы импортировали встроенный класс IntegerField и описали бы поле им.

WWW

В WTForms множество встроенных классов для описания полей, посмотреть все можно здесь. Также можно создать поле кастомного класса.

О полях нужно знать следующее.

  1. Каждое поле может принимать набор аргументов, общий для всех типов полей.
  2. Почти каждое поле имеет HTML-представление, так называемый виджет.
  3. Для каждого поля можно указать набор валидаторов.
  4. Некоторые поля могут принимать дополнительные аргументы. Например, для SelectField можно указать набор возможных значений.
  5. Поля можно добавлять к уже существующим формам. И можно модифицировать, изменять значения на лету. Это особенно полезно, когда нужно чуть изменить поведение формы для одного конкретного случая, при этом не создавать новый класс формы.
  6. Поля могут провоцировать ошибки валидации по заданным правилам, они будут храниться в form.field.errors.

Работа с формой

Попробуем отобразить форму. Обычный workflow работы с формами состоит из двух этапов.

  1. GET-запрос страницы, на которой нам нужно отобразить нашу форму. В этот момент мы должны создать инстанс нашей формы, настроить его, если потребуется, и передать шаблонизатору в контексте для рендеринга. Обычно это делается в обработчике (action) контроллера GET-запроса и чем-то похожем в зависимости от HTTP-фреймворка, которым ты пользуешься (или не пользуешься, для WTForms это не проблема). Другими словами, в обработчике роута вроде GET /users/new. К слову, в Django или Rails ты выполняешь схожие действия. В первом создаешь такую же форму и передаешь ее шаблонизатору в template context, а во втором создаешь в текущем контексте новый, еще не сохраненный объект через метод new (@user = User.new).
  2. POST-запрос страницы, с которой мы должны получить данные формы (например, POST /users) и как-то процессить: выполнить валидацию данных, заполнить поля объекта из формы для сохранения в БД.


Генерация формы (GET /users/new)

Создадим инстанс нашей предварительно определенной формы:

user_form = UserForm()
type(user_form)
# __main__.UserForm

К каждому полю формы мы можем обратиться отдельно по ее атрибуту:

type(form.first_name)
# wtforms.fields.core.StringField

В самом простом случае это все. Теперь инстанс нашей формы можно передать шаблонизатору для отображения:

def new(self, request):
    user_form = UserForm()

    render('new_user.html', {
        'form': user_form,
    })

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

Отлично, передали нашу форму в шаблонизатор. Как ее отрендерить в шаблоне? Проще простого. Напомню, что мы рассматриваем процесс на примере Jinja2.

{{ form.first_name.label }}
{% if form.first_name.errors %}
    <ul class="errors">
        {% for error in form.first_name.errors %}
            <li>{{ error }}</li>{% endfor %}
    </ul>
{% endif %}
{{ form.first_name() }}

Код выше с user_form в качестве form будет преобразован шаблонизатором в следующую разметку.

<label for="first_name">First name</label>
<input id="first_name" name="first_name" type="text" value="">

Здесь происходит вот что.

  1. В первой строке мы обратились к атрибуту label поля first_name нашей формы. В нем содержится HTML-код лейбла нашего поля first_name. Текст берется из описания класса формы из соответствующего атрибута поля.
  2. Затем мы проверили содержимое списка errors нашего поля. Как нетрудно догадаться, в ней содержатся ошибки. На данный момент ошибок в нем нет, поэтому блок не вывел ничего. Однако если бы эта форма уже заполнялась и была заполнена неверно (например, валидатор от 6 до 30 по длине не пропустил значение), то в список поля попала бы эта ошибка. Мы увидим работу валидаторов дальше.
  3. И наконец, в последней строке мы рендерим сам тег input, вызывая метод .first_name() нашего инстанса формы.

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


Парсинг пейлоада (POST /users)

Следующий шаг — получить данные формы на сервере и как-то их обработать. Этап состоит из нескольких шагов.

  1. Получить POST-данные (это может происходить по-разному в зависимости от того, используешь ли ты фреймворк и какой конкретно, если используешь).
  2. Распарсить POST-данные через наш инстанс формы.
  3. Проверить (валидировать) корректность заполнения. Если что-то не так, вернуть ошибки.
  4. Заполнить данными формы требуемый объект. Это опционально, но, если ты пользуешься ORM, велика вероятность, что по данным формы тебе нужно создать объект в БД.

В нашем случае объект в БД — это пользователь, объект класса User.

async def create(self, request):
    # Получаем payload. Для aiohttp это не самый оптимальный
    # способ для больших payload. Взят для краткости
    payload = await request.post()
    # Создаем новый инстанс нашей формы и заполняем его данными,
    # пришедшими с клиента
    form = UserForm(payload)

    # Если данные с клиента проходят валидацию
    if form.validate():
        # Создаем новый объект User
        user = User()
        # Заполняем его атрибуты данными формы
        form.populate_obj(user)
        # ...
        # Сохраняем юзера в БД, редиректим дальше

Мы отрендерили форму, получили данные с клиента обратно, проверили их и записали в БД. При этом мы не погружались во внутренности HTML, ID полей, имена и их сопоставления на клиенте и сервере. Не правда ли, удобно?


Опции для частичного парсинга пейлоада

Если ты внимательно читал предыдущий раздел, у тебя непременно возник вопрос: а как модель пользователя заполняется данными формы? Ведь форма ничего не знает о полях ORM (которой может не быть). Так как же происходит маппинг полей формы к объекту в функции populate из WTForms? Проще всего посмотреть код этой функции.

Signature: form.populate_obj(obj)
Source:
    def populate_obj(self, obj):
        """
        Populates the attributes of the passed `obj`
        with data from the form's fields.

        :note: This is a destructive operation;
               Any attribute with the same name
               as a field will be overridden. Use with caution.
        """
        for name, field in iteritems(self._fields):
            field.populate_obj(obj, name)

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

В большинстве случаев это работает отлично. Даже для полей-ассоциаций: у пользователя может быть поле, значением которого выступает реляция в БД, например группа, к которой принадлежит пользователь. В этом случае воспользуйся классом wtforms.fields.SelectField, передав choices=[...] со списком возможных значений реляций, и на сервере при наличии ORM это будет распознано без проблем.

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

  1. Не использовать встроенную функцию populate_obj вообще и обрабатывать все поля вручную, получая доступ к ним через атрибут .data каждого поля формы вроде form.f_name.data.
  2. Написать свой метод для заполнения объекта данными формы.

Мне больше нравится второй вариант (хоть он и имеет ограничения). Например, так:

from wtforms.compat import iteritems, itervalues, with_metaclass

def populate_selective(form, obj, exclude=[]):
    for name, field in filter(lambda f: f[0] not in exclude, iteritems(form._fields)):
        field.populate_obj(obj, name)

Теперь можно использовать из формы только те поля, которые нужны:

populate_selective(form, user, exclude=['f_name', 'l_name', 'city',])

А с остальными разбираться по собственной логике.


Валидаторы

Еще один вопрос, ответ на который ты наверняка уже понял по контексту: как работает функция form.validate()? Она проверяет как раз те самые списки валидаторов с параметрами, которые мы указывали при определении класса формы. Давай попробуем позаполнять различные значения в строковых полях, которые в реальном приложении в нашу форму будет предоставлять с клиента пользователь, и посмотрим, как среагирует валидатор.

form = UserForm()

form.first_name.data = 'Johnny'
form.last_name.data = 'Doe'
form.email.data = 'invalid_email'
form.password.data = 'super-secret-pass'

Попробуем валидировать эту форму.

form.validate()
# False

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

form.first_name.errors
# []

Все правильно, в первом поле ошибок не было, список валидаторов [validators.Length(min=5, max=30)] пройден, так как имя Johnny удовлетворяет единственному валидатору. Посмотрим другие.

form.last_name.errors
# ['Field must be between 5 and 30 characters long.']
form.email.errors
# ['Invalid email address.']
form.password.errors
# []

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

{% if form.first_name.errors %}
    <ul class="errors">
        {% for error in form.first_name.errors %}
            <li>{{ error }}</li>{% endfor %}
    </ul>
{% endif %}

INFO

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

С полным списком встроенных валидаторов можно ознакомиться здесь, а если их не хватит, то WTForms позволяет определить и собственные.

Динамическое изменение свойств полей формы

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

  • id — атрибут ID HTML-виджета при рендеринге;
  • name — имя виджета (свойство name в HTML), по которому будет делаться сопоставление;
  • ошибки, валидаторы и так далее.

Все это возможно благодаря тому, что все классы полей наследуются от базового класса wtforms.fields.Field.

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

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

Все эти моменты лучше настраивать прямо перед самым рендерингом формы у готового инстанса формы: совершенно незачем тащить это в определение класса. Но как это сделать? Вспомним, что наша форма — это обычный Python-объект и мы можем управлять его атрибутами!

Зададим дефолтное значение поля first_name (другой вариант — через default):

form.first_name.data = 'Linus'

У поля любого класса есть словарь render_kw. Он предоставляет список атрибутов, которые будут отрендерены в HTML-теге (виджете).

# Теперь поле хорошо выглядит с Bootstrap!
form.last_name.render_kw['class'] = 'form-control'

Ну и зададим кастомный data-атрибут для проверки на дублирование аккаунта:

form.users.render_kw['data-url'] = request.app.router['api_users_search'].url_for()

Сборные и наследуемые формы

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

Определим базовый класс формы:

class UserBaseForm(Form):
    email = StringField('Email', [validators.Email()])
    password = StringField('Password')

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

class UserExtendedForm(UserBaseForm):
    first_name = StringField('First name', [validators.Length(min=4, max=25)])
    last_name = StringField('Last name', [validators.Length(min=4, max=25)])

Создадим две формы и посмотрим, какие поля у них есть.

base_form = UserBaseForm()
base_form._fields
# OrderedDict([('email', <wtforms.fields.core.StringField at 0x106b1df60>),
# ('password', <wtforms.fields.core.StringField at 0x106b1d630>)])

А теперь посмотрим, что содержит наша расширенная форма:

extended_from = UserExtendedForm()
extended_from._fields
# OrderedDict([('email', <wtforms.fields.core.StringField at 0x106b12a58>),
# ('password', <wtforms.fields.core.StringField at 0x106b12f60>),
# ('first_name', <wtforms.fields.core.StringField at 0x106b12e80>),
# ('last_name', <wtforms.fields.core.StringField at 0x106b12ef0>)])

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

Другой способ создания сложных форм — уже упомянутый FormField. Это отдельный класс поля, который может наследовать уже существующий класс формы. Например, вместе с Post можно создать и нового User для этого поста, префиксив названия полей.

Заполнение реляционных полей (one-to-many, many-to-many)

Одна (не)большая проблема при построении форм — это реляции. Они отличаются от обычных полей тем, что их представление в БД не соответствует as is тому, что должно отображаться в поле формы, а при сохранении они могут требовать препроцессинга. И эту проблему легко решить с WTForms. Поскольку мы знаем, что поля формы можно изменять динамически, почему бы не использовать это свойство для ее предзаполнения объектами в нужном формате?

Разберем простой пример: у нас есть форма создания поста и для него нужно указать категорию и список авторов. Категория у поста всегда одна, а авторов может быть несколько. Кстати, схожий способ используется прямо на Xakep.ru (я использую WTForms на бэкенде «Хакера», PHP с WP у нас только в публичной части).

Отображать реляции в форме мы можем двумя способами.

  1. В обычном <select>, который будет отрендерен как выпадающий список. Этот способ подходит, когда у нас мало возможных значений. Например, список категорий поста — их не более дюжины, включая скрытые.
  2. В динамически подгружаемом списке, аналогичном списку тегов, которые ты встречаешь на других сайтах. Для реализации его нам поможет простой трюк.

В первом варианте у нашей формы есть поле category, в базе оно соответствует полю category_id. Чтобы отрендерить это поле в шаблоне как select, мы должны создать у формы атрибут category класса SelectField. При рендеринге в него нужно передать список из возможных значений, который формируется запросом в БД (читай: список возможных категорий для поста), а также установить дефолтное значение.

# Импортируем хелпер шаблонов, который представляет объект Category
# как строку в нужном формате, аналог __str__. Нужно для удобства
from admin.template_helpers.categories import humanize_category

# Выберем все категории из БД
categories = Category.select().all()

# Установим дефолтное значение первой из них
form.categories.data = [str(categories[0].id)]

# Передадим список всех возможных вариантов для SelectField
# В шаблоне отрендерится <select> c выбранным указанным <option>
# Формат — список кортежей вида (<идентификатор>, <человекочитаемое представление>)
form.categories.choices = [(c.id, humanize_category(c)) for c in categories]

В результате у поля списка появятся предзаполненные значения.

Предзаполненный select с установленным значением через WTForms

С авторами постов (пользователями) или журналами такой трюк не пройдет. Первых у нас около ста тысяч, и, разумеется, ни рендерить, ни искать в таком гигантском select’е будет невозможно. Один из вариантов решения задачи — использовать библиотеку Select2. Она позволяет превратить любой input в динамически подгружаемый список а-ля список тегов простым присвоением нужного класса, а данные подгружать по предоставленному URL. Мы уже умеем делать это через знакомый словарь render_kw.

form.issues.render_kw['class'] = 'live_multiselect'
form.issues.render_kw['data-url'] = request.app.router['api_issues_search'].url_for()

А дальше простым добавлением в шаблон jQuery-функции превращаем все input c нужным классом в динамически подгружаемые селекторы (обработчик поиска, разумеется, должен быть на сервере):

$(".live_multiselect").each(function (index) {
    let url = $(this).data('url')
    let placeholder = $(this).data('placeholder')

    $(this).select2({
        tags: true,
        placeholder: placeholder,
        minimumInputLength: 3,
        ajax: {
            url: url,
            delay: 1000,
            dataType: 'json',
            processResults: function (data) {
                let querySet = { results: data };
                return querySet
            }
        }
    });
});

В результате получаем удобный переиспользуемый виджет.

Динамически подгружаемый селектор средствами WTForms и Select2


Кастомные виджеты и расширения

Пример выше может показаться частным, однако он подводит к важной проблеме. Хорошо, что наша задача решается плагином Select2, который позволяет буквально добавлением одного класса и щепотки JS получить необходимую функциональность. Однако как быть, если нам нужен полностью собственный шаблон для поля или даже полностью свое сложное поле с кастомным шаблоном, поведением и валидаторами?

К счастью, WTForms позволяет создавать нам собственные виджеты (классы-генераторы HTML-шаблонов для рендеринга полей). Мы можем сделать это двумя способами:

  1. Создать собственный на базе существующего (class CustomWidget(TextInput):…), расширив его поведение и переопределив методы, включая __call__. Например, обернуть в дополнительный HTML-шаблон.
  2. Создать полностью собственный виджет, не наследуясь от существующих встроенных.

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

Интегрировать собственный виджет тоже несложно. У каждого поля есть атрибут widget. Мы можем указать наш виджет в качестве этого keyword-аргумента при определении поля в классе формы или, если кастомный виджет нужен не всегда, присваивать его полю динамически.

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

Вместо заключения

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

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

ПОДПИСАТЬСЯ - Бюро121