Flask-WTForms и валидаторы

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

Создадим каркас приложения.

from flask import Flask, request

app = Flask(__name__)

@app.route('/registration', methods=['POST'])
def registration():
    pass

if __name__ == '__main__':
    app.run(debug=True)

Приложение имеет единственный эндпойнт, который будет работать с POST-запросами. По идее, на странице domen.ru/registration юзера будет ждать форма с обязательными и необязательными полями, который он будет заполнять, чтобы продолжить пользоваться функционалом. И для реализации такой формы нет необходимости придумывать велосипед, можно просто воспользоваться готовым функционалом расширения Flask-WTF. Но для начала потребуется его установить консольной командой:

pip install Flask-WTF

Сразу оставлю ссылку на официальную документацию расширения.

Делаем импорт объекта FlaskForm и напишем саму форму в виде класса, наследованного от FlaskForm.

from flask_wtf import FlaskForm
class RegistrationForm(FlaskForm):
    email = StringField()
    phone = IntegerField()
    name = StringField()
    address = StringField()
    index = IntegerField()
    comment = StringField()

Функцию registration() заполним следующим содержимым:

@app.route('/registration', methods=['POST'])
def registration():
    form = RegistrationForm()

    if form.validate_on_submit():
        ame = form.name.data

        print(form)
        print(email, phone, name, password)
        return f'Hello {name} welcome to our site!'
    return f'{form.errors}', 404

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

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

if __name__ == '__main__':
    app.config["WTF_CSRF_ENABLED"] = False #отключаем проверку безопасности WTForms
    app.run(debug=True)

Код нашего приложения должен выглядеть так:

from flask import Flask, request
from flask_wtf import FlaskForm

app = Flask(__name__)

class RegistrationForm(FlaskForm):
    email = StringField()
    phone = IntegerField()
    name = StringField()
    address = StringField()
    index = IntegerField()
    comment = StringField()

@app.route('/registration', methods=['POST'])
def registration():
    form = RegistrationForm()

    if form.validate_on_submit():
        email, phone, name, address = form.email.data, form.phone.data, form.name.data, form.address.data

        print(form)
        print(email, phone, name, address)
        return f'Hello {name} welcome to our site!'
    return f'{form.errors}', 404

if __name__ == '__main__':
    app.config["WTF_CSRF_ENABLED"] = False #отключаем проверку безопасности WTForms
    app.run(debug=True)

Чтобы протестировать работу приложения, нам нужно как-то отправить по адресу domen.ru/registration POST-запросы при запущенном приложении. Для этого потребуется удобная консольная утилита Curl. Устанавливается командой:

sudo apt install curl

Теперь запустите приложение, а в терминале пропишите:

curl -X POST http://127.0.0.1:5000/registration -F email=mymail@mail.ru -F phone=123 -F name=Artem -F address=City

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

Добавим импорты:

from wtforms.fields.numeric import IntegerField
from wtforms.fields.simple import StringField
from wtforms.validators import InputRequired, Email, NumberRange

И немного модернизируем наш класс:

class RegistrationForm(FlaskForm):
    email = StringField(validators=[InputRequired(), Email()])
    phone = IntegerField(validators=[InputRequired(), NumberRange(min=10000000000, max=999999999)])
    name = StringField(validators=[InputRequired()])
    address = StringField()
    index = IntegerField()
    comment = StringField()

Теперь поле email обязательное к заполнению и проверяется на валидность введенных данных (если валидатор Email не встал, поставьте командой: pip install email_validator), поле phone тоже обязательное и проверяется на корректность введенных чисел в диапазоне от 10000000000 до 999999999, а поле name просто обязательное к заполнению.

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

from flask import Flask, request
from flask_wtf import FlaskForm
from wtforms.fields.numeric import IntegerField
from wtforms.fields.simple import StringField
from wtforms.validators import InputRequired, Email, NumberRange

app = Flask(__name__)

class RegistrationForm(FlaskForm):
    email = StringField(validators=[InputRequired(), Email()])
    phone = IntegerField(validators=[InputRequired(), NumberRange(min=10000000000, max=999999999)])
    name = StringField(validators=[InputRequired()])
    address = StringField()
    index = IntegerField()
    comment = StringField()


@app.route('/registration', methods=['POST'])
def registration():
    form = RegistrationForm()

    if form.validate_on_submit():
        email, phone, name, address = form.email.data, form.phone.data, form.name.data, form.address.data

        print(form)
        print(email, phone, name, address)
        return f'Hello {name} welcome to our site!'
    return f'{form.errors}', 404

if __name__ == '__main__':
    app.config["WTF_CSRF_ENABLED"] = False #отключаем проверку безопасности WTForms
    app.run(debug=True)

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

  • InputRequired(message=None): проверяет, что для поля были предоставлены данные. Другими словами, значение поля — не пустая строка. Этот валидатор также устанавливает флаг обязательного поля формы для заполнения.
  • IPAddress(ipv4=True, ipv6=False, message=None): проверяет IP-адрес. Аргумент Ipv4 — если True, принимать адреса IPv4 как действительные (по умолчанию True). Аргумент Ipv6 — если True, принимать IPv6-адреса как действительные (по умолчанию False)
  • Length(min=- 1, max=- 1, message=None): проверяет длину строки. Аргумент min — минимальная необходимая длина строки. Если не указан, минимальная длина проверяться не будет. Аргумент max — максимальная длина строки. Если не указан, максимальная длина проверяться не будет.
  • MacAddress(message=None): проверяет MAC-адрес. Аргумент message — сообщение об ошибке, которое будет выдано в случае ошибки проверки.
  • NumberRange(min=None, max=None, message=None): проверяет, что число имеет минимальное и/или максимальное значение включительно. Это будет работать с любым сопоставимым типом чисел, таким как числа с плавающей запятой и десятичные дроби, а не только с целыми числами.
  • Optional(strip_whitespace=True): разрешает пустой ввод (необязательное поле) и останавливает продолжение цепочки проверки. Если ввод пуст, также удаляются предыдущие ошибки из поля (например, ошибки обработки). Если аргумент strip_whitespace=True (по умолчанию), то также остановит цепочку проверки, если значение поля состоит только из пробелов.
  • Regexp(regex, flags=0, message=None): проверяет поле на соответствие регулярному выражению, предоставленному пользователем. Аргумент regex — cтрока регулярного выражения для использования. Также может быть скомпилированным шаблоном регулярного выражения. Аргумент flags — используемые флаги регулярного выражения, например re.IGNORECASE. Игнорируется, если регулярное выражение не является строкой.
  • URL(require_tld=True, message=None): простая проверка URL на основе регулярного выражения. Вероятно потребуется его проверка на доступность другими способами.
  • UUID(message=None): проверяет UUID.
  • AnyOf(values, message=None, values_formatter=None): сравнивает входящие данные с последовательностью допустимых входных данных. Аргумент values ​​- последовательность допустимых входных данных. Аргумент values_formatter — функция, используемая для форматирования списка значений в сообщении об ошибке message.
  • NoneOf(values, message=None, values_formatter=None): сравнивает входящие данные с последовательностью неверных входных данных. Аргумент values ​​- последовательность допустимых входных данных. Аргумент values_formatter — функция, используемая для форматирования списка значений в сообщении об ошибке message.

Создание собственных валидаторов

У встроенных валидаторов имеется ограниченный, минимально необходимый функционал. Для его расширения проще всего писать собственные валидаторы и определять в них кастомную логику. Давайте напишем кастомный валидатор для поля phone нашей формы, чтобы ограничить длину симоволов. Сделаем это несколькими способами и в отдельном файле, откуда потом импортируем:

from flask_wtf import FlaskForm
from wtforms import Field, ValidationError


def easy_number_length(form: FlaskForm, field: Field, message='Значение не в диапазоне 10 знаков.'):
    '''Самое элегантное решение поставленной задачи. Проверяет не только длину, но и на "только цифры".'''
    data = field.data
    if not (str(data).isdigit() and len(str(data)) == 10):
        raise ValidationError(message)


def number_length(min=10, max=10, message=None):
    '''Имеет настраиваемый диапазон по количеству знаков.'''
    if not message:
        message = f'Значение должно быть от {min} до {max} знаков.'
    def _length(form, field):
        data = field.data
        if max < len(str(data)) or len(str(data)) < min:
            raise ValidationError(message)

    return _length


class NumberLength:
    '''По сути тоже самое, но оформлено в виде класса.'''
    def __init__(self, min=10, max=10, message=None):
        self.min = min
        self.max = max
        if not message:
            message = f'Значение должно быть от {min} до {max} знаков.'
        self.message = message

    def __call__(self, form, field):
        data = field.data
        if self.max < len(str(data)) or len(str(data)) < self.min:
            raise ValidationError(self.message)

Остается только сделать нужный импорт и добавить валидатор к полю. Я добавлю последний.

phone = IntegerField(validators=[InputRequired(message='Обязательное поле!'), NumberLength(min=10, max=10)])

Ссылка на документацию с кастомными валидаторами.

Тестирование валидаторов с Unittest

В тестировании я далеко не спец, но и статья пишется для таких же нулей, чтобы просто иметь общее представление. Для тестирвования валидаторов полей написанной выше формы я буду использовать тестовый клиент нашего приложения, посылая POST запросы к эндпойнту. Тесты, конечно же, нужно писать в отдельной директории (пакете с файлом __init__.py). Ниже представлен код тестов, который наверняка можно дополнить, что я рекомендуб сделать.

from app import app
import unittest


class TestApp(unittest.TestCase):

    def setUp(self):
        app.config['TESTING'] = True
        app.config['DEBUG'] = True
        app.config["WTF_CSRF_ENABLED"] = False
        self.app = app.test_client()
        self.base_url = '/registration'

    fields = {
        'email': 'mail@mail.ru',
        'phone': 9506006000,
        'name': 'Artem',
        'address': 'My City',
        'index': 603600,
        'comment': 'Ok',
    }


    def test_path_registration(self):
        response = self.app.post(self.base_url, data=self.fields)
        self.assertEqual(response.request.path, '/registration')

    def test_correct_registration(self):
        response = self.app.post(self.base_url, data=self.fields)
        response_text = response.data.decode('utf-8')
        self.assertIn('Successfully registered', response_text)

    def test_invalid_email(self):
        bad_value = ('', 112, 'asasas.ru')
        for item in bad_value:
            with self.subTest(item=item):
                email = {'email': item}
                self.fields.update(email)
                response = self.app.post(self.base_url, data=self.fields)
                response_text = response.data.decode('utf-8')
                self.assertIn('Invalid', response_text)

    def test_invalid_phone(self):
        bad_value = ('', 112, '9503330455', 89500770744)
        for item in bad_value:
            with self.subTest(item=item):
                new_phone = {'phone': item}
                self.fields.update(new_phone)
                response = self.app.post(self.base_url, data=self.fields)
                response_text = response.data.decode('utf-8')
                self.assertIn('Invalid', response_text)

    def test_invalid_name(self):
        self.fields.update({'name': ''})
        response = self.app.post(self.base_url, data=self.fields)
        response_text = response.data.decode('utf-8')
        self.assertIn('Invalid', response_text)

    def test_invalid_address(self):
            self.fields.update({'address': ''})
            response = self.app.post(self.base_url, data=self.fields)
            response_text = response.data.decode('utf-8')
            self.assertIn('Invalid', response_text)

    def test_invalid_index(self):
        bad_values = ('', '123')
        for item in bad_values:
            with self.subTest(item=item):
                self.fields.update({'index': item})
                response = self.app.post(self.base_url, data=self.fields)
                response_text = response.data.decode('utf-8')
                self.assertIn('Invalid', response_text)

    def test_comment(self):
        values = ('', '123', 333)
        for item in values:
            with self.subTest(item=item):
                self.fields.update({'comment': item})
                response = self.app.post(self.base_url, data=self.fields)
                response_text = response.data.decode('utf-8')
                self.assertIn('Successfully registered', response_text)



if __name__ == "__main__":
    unittest.main()

А напоследок пример симпатичной формы регистрации с кастомным валидатором и HTML кодом для страницы.

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, \
    SubmitField
from wtforms.validators import ValidationError, DataRequired, \
    Email, EqualTo, Length

class CreateUserForm(FlaskForm):
    username = StringField(label=('Username'), 
        validators=[DataRequired(), 
        Length(max=64)])
    email = StringField(label=('Email'), 
        validators=[DataRequired(), 
        Email(), 
        Length(max=120)])
    password = PasswordField(label=('Password'), 
        validators=[DataRequired(), 
        Length(min=8, message='Password should be at least %(min)d characters long')])
    confirm_password = PasswordField(
        label=('Confirm Password'), 
        validators=[DataRequired(message='*Required'),
        EqualTo('password', message='Both password fields must be equal!')])

    receive_emails = BooleanField(label=('Receive merketting emails.')) # флаг

    submit = SubmitField(label=('Submit')) # кнопка отправки формы

Валидатор для юзернейма:

def validate_username(self, username):
        excluded_chars = " *?!'^+%&/()=}][{$#"
        for char in self.username.data:
            if char in excluded_chars:
                raise ValidationError(
                    f"Character {char} is not allowed in username.")

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

А это HTML код для страницы с нашей формой. Но лучше всё же использовать шаблоны Jinja и наследование от базового шаблона.

<div class="container">
    <h2>Registration Form</h2>
    {% for field, errors in form.errors.items() %}
    {{ ', '.join(errors) }}
    {% endfor %}
    <form class="form-horizontal" method="POST" action="">
        {{ form.csrf_token() }}
        <div class="form-group">
            {{ form.username.label }}
            {{ form.username(class="form-control") }}
        </div>
        <div class="form-group">
            {{ form.email.label }}
            {{ form.email(class="form-control") }}
        </div>
        <div class="form-group">
            {{ form.password.label }}
            {{ form.password(class="form-control") }}
        </div>
        <div class="form-group">
            {{ form.confirm_password.label }}
            {{ form.confirm_password(class="form-control") }}
        </div>
        <div class="form-group">
            {{ form.receive_emails.label }}
        </div>
        <div class="form-group">
            {{ form.submit(class="btn btn-primary")}}
        </div>
    </form>
</div>

На этом всё. Если есть вопросы, пишите в чат.