Обработка сложных форм на Python с помощью WTForms

wtforms

Обработка HTML-форм в веб-приложениях — несложная задача. Казалось бы, о чем говорить: набросал форму в шаблоне, создал обработчики на сервере, и готово. Проблемы начинаются, когда форма разрастается: нужно следить за полями, их ID, атрибутами name, корректно маппить атрибуты на бэкенде при генерации и процессинге данных. А если часть формы нужно еще и переиспользовать, то разработка превращается в постоянную рутину: приходится бесконечно копировать атрибуты тегов с клиента на сервер и делать копипаст однотипного кода. Однако есть способы сделать работу с формами удобной.

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

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

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

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

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

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

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

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

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

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

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

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

Установка

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

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

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

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

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

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

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

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

В 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)

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

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

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

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

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

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

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

  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.

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

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

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

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

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

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

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

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

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

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

Валидаторы

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Другой способ создания сложных форм — уже упомянутый 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. При рендеринге в него нужно передать список из возможных значений, который формируется запросом в БД (читай: список возможных категорий для поста), а также установить дефолтное значение.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Понравилась статья? Поделиться с друзьями:
Добавить комментарий