Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 4 из 20 Средний 40 мин 120 XP

Формы в Flask: приём данных от пользователя

Четвёртый урок курса Flask: сайт начинает слушать гостя. HTML-формы, request.form и request.args, серверная валидация, flash-сообщения и защита от повторной отправки формы.

Редакция Питоники

Ваша кофейня из прошлых уроков научилась красиво говорить: маршруты, шаблоны, наследование base.html. Но всё это — монолог. Сайт говорит, посетитель слушает. А теперь гость хочет написать, что раф у вас лучший в городе, а капучино — на тройку с минусом. Для этого сайту нужна форма: поля, кнопка «Отправить» и серверный код, который примет данные, проверит их и что-то с ними сделает. Сегодня превращаем сайт из витрины в диалог.

Разберём четыре вещи: как HTML-форма упаковывает и отправляет данные, где во Flask их забрать (request.form и request.args), как валидировать ввод руками и как сообщать об ошибках по-человечески — через flash-сообщения. Сразу договоримся о формате: Flask-сервер в браузерной песочнице не поднять, поэтому серверные блоки показаны с реальным выводом. А вот шаблонизатор Jinja2 из урока 3 работает здесь полностью — рендер формы с ошибками вы запустите прямо на странице.

HTML-форма: что именно браузер отправляет на сервер

Форма — это обычный HTML с тремя важными деталями. Тег form объявляет, куда отправлять данные (action) и каким методом (method). У каждого поля обязан быть атрибут name — это ключ, под которым значение уедет на сервер. Кнопка type="submit" запускает отправку. Вот форма отзыва для нашей кофейни:

templates/form.html — разметка формы отзыва
<form action="/review" method="post">
  <p>
    <label>Ваше имя<br>
    <input type="text" name="name"></label>
  </p>
  <p>
    <label>Отзыв<br>
    <textarea name="text" rows="4" cols="40"></textarea></label>
  </p>
  <p>
    <label>Оценка<br>
    <select name="score">
      <option value="5">5 - отлично</option>
      <option value="4">4 - хорошо</option>
      <option value="3">3 - так себе</option>
    </select></label>
  </p>
  <p><button type="submit">Отправить отзыв</button></p>
</form>
Это HTML-разметка, а не Python-код — на странице она не выполняется. Если убрать у поля атрибут name, его значение молча не доедет до сервера: самая частая причина «пустых» форм у новичков.

Гость вводит имя, пишет отзыв, выбирает оценку и жмёт кнопку. Браузер собирает пары «имя поля = значение» в строку вида name=Анна&text=...&score=5 и отправляет её POST-запросом на адрес /review. Дальше эстафета переходит вашему Python-коду.

GET или POST: два способа передать данные

У HTTP-запроса есть метод — глагол, который сообщает серверу намерение браузера. GET значит «дай страницу», POST — «прими данные». Одни и те же поля можно отправить обоими методами, но ведут они себя по-разному, и выбор — не вкусовщина, а правило семантики и безопасности:

GETPOST
Где лежат данныев самом URL: /menu?sort=priceв теле запроса, в адресе не видны
Ограничение объёмапара тысяч символовпрактически нет
Обновление страницы (F5)безопасно повторитьбраузер предложит отправить данные заново
Для чего годитсяпоиск, фильтры, страницы каталогаотзывы, регистрация, пароли, файлы
Ссылка и закладкапараметры сохраняются в URLданных в URL нет, закладка бессмысленна

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

request.form: принимаем POST во view-функции

Во Flask всё, что прислал браузер, доступно через объект request. Импортируйте его из flask — и данные формы окажутся в request.form: это словареподобный объект, где ключи — значения атрибутов name, а значения — то, что ввёл гость. Ещё маршрут должен разрешить POST явно: без указания методов Flask отвечает только на GET.

app.py — маршрут, который принимает форму
from flask import Flask, request

app = Flask(__name__)

@app.route("/review", methods=["GET", "POST"])
def review():
    if request.method == "POST":
        name = request.form.get("name", "Аноним")
        text = request.form.get("text", "")
        score = request.form.get("score", "5")
        return f"Спасибо, {name}! Оценка {score}: {text}"
    return "Здесь будет форма"

if __name__ == "__main__":
    app.run()
Вывод
$ curl -X POST -d "name=Анна&text=Лучший раф в городе&score=5" http://127.0.0.1:5000/review
Спасибо, Анна! Оценка 5: Лучший раф в городе
Сервер в браузере не запустить — это реальный код Flask с ожидаемым результатом. curl -X POST отправляет POST-запрос, ключ -d передаёт поля формы ровно так, как это делает кнопка «Отправить».

Ветвление if request.method == "POST" — классический приём: один и тот же маршрут и рисует форму (GET), и принимает её (POST). Метод .get() со вторым аргументом возвращает значение по умолчанию, если поля нет, — форма без имени не уронит сайт.

request.args: а это данные из URL

Пары вида ?sort=price&page=2 после вопросительного знака называют строкой запроса (query string). Flask кладёт их не в request.form, а в request.args — механика чтения та же самая:

app.py — параметры из адресной строки
@app.route("/menu")
def menu():
    sort = request.args.get("sort", "name")
    page = request.args.get("page", "1")
    return f"Сортировка: {sort}, страница {page}"
Вывод
$ curl "http://127.0.0.1:5000/menu?sort=price&page=2"
Сортировка: price, страница 2
Кавычки вокруг URL нужны, чтобы терминал не съел амперсанд. Это реальный код с ожидаемым выводом — в песочнице сервер не запускается.

Итого четыре места, где прячутся данные запроса: request.form — поля формы из POST, request.args — параметры из URL, request.values — объединение обоих, request.json — JSON в теле для API. Для HTML-форм почти всегда хватает первых двух.

Ошибка 405: маршрут есть, метод не разрешён

Забудете methods=["POST"] — POST-запрос упадёт с ошибкой, которая сначала выглядит загадочно. Проверим на приложении, где маршрут объявлен без методов:

Терминал: POST на маршрут без methods
$ curl -i -X POST http://127.0.0.1:5000/review
Вывод
HTTP/1.1 405 METHOD NOT ALLOWED
Content-Type: text/html; charset=utf-8
Allow: GET, HEAD, OPTIONS

<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
<p>The method is not allowed for the requested URL.</p>
Реальный ответ Flask 3.x. Заголовок Allow честно перечисляет разрешённые методы; их порядок может отличаться между версиями Werkzeug.

Валидация руками: не верь данным от браузера

Данные формы может прислать кто угодно: curl, скрипт на requests, злоумышленник с перехватчиком трафика. Поэтому проверять ввод нужно на сервере, даже если в HTML расставлены required и maxlength. Атрибуты HTML — удобство для гостя, серверная валидация — защита бэкенда.

request.form ведёт себя почти как словарь, поэтому логику валидации легко отрепетировать без сервера — на обычных словарях. Этот блок настоящий, запустите его прямо на странице:

Валидация полей формы (можно запустить)
# request.form похож на словарь, поэтому валидацию
# можно отработать на тестовых данных без сервера
form = {"name": "  ", "text": "Раф просто тает во рту", "score": "11"}

errors = []
if not form.get("name", "").strip():
    errors.append("Укажите имя")
if len(form.get("text", "")) < 10:
    errors.append("Отзыв короче 10 символов")
if form.get("score") not in {"1", "2", "3", "4", "5"}:
    errors.append("Оценка должна быть от 1 до 5")

for e in errors:
    print(e)
Вывод
Укажите имя
Оценка должна быть от 1 до 5

Три детали, на которые стоит взглянуть. strip() у имени: строка из пробелов честно заполняет поле, но имени в ней нет — обрезаем и отсекаем пустоту. Оценка приходит строкой "11", поэтому сравниваем со множеством строковых значений, а не чисел: из формы всегда приходит строка. И главное: поле score — это select из трёх вариантов, и проверять его всё равно нужно. Недоброжелатель отправит в select любой текст за одну команду curl.

Почему .get(), а не квадратные скобки

Тестируем обработку формы без сервера

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

Обработка формы как обычная функция (можно запустить)
def save_review(form):
    name = form.get("name", "").strip() or "Аноним"
    score = int(form.get("score", "5"))
    return {"author": name, "score": score}

print(save_review({"name": " Марина ", "score": "4"}))
print(save_review({}))
Вывод
{'author': 'Марина', 'score': 4}
{'author': 'Аноним', 'score': 5}

Выражение form.get("name", "").strip() or "Аноним" — стойкая идиома: если после обрезки пробелов имя пустое, or подставляет запасное значение. А int() превращает строку "4" в число 4 — не забывайте, что из формы приходят только строки, и «4» плюс «5» в Python даст строку "45".

Показываем ошибки в шаблоне: форма не забывает введённое

Гость ошибся в одном поле из пяти — и получит страницу «Отзыв не отправлен» с пустой формой? Так делают сайты, которые не любят гостей. Правильная форма показывает список ошибок и сохраняет всё, что пользователь уже ввёл. Это чистая работа Jinja2, и она запускается здесь целиком:

Шаблон формы с ошибками (можно запустить)
from jinja2 import Template

template = Template("""<h1>Оставить отзыв</h1>
{% if errors %}
<ul class="errors">
  {% for e in errors %}
  <li>{{ e }}</li>
  {% endfor %}
</ul>
{% else %}
<p>Спасибо, отзыв принят!</p>
{% endif %}
<form action="/review" method="post">
  <input name="name" value="{{ form.name }}">
  <textarea name="text">{{ form.text }}</textarea>
  <input name="score" value="{{ form.score }}">
  <button type="submit">Отправить</button>
</form>""", trim_blocks=True, lstrip_blocks=True)

form = {"name": "Игорь", "text": "Кофе холодный", "score": "2"}
errors = ["Отзыв короче 10 символов"]

print(template.render(form=form, errors=errors))
print()
print(template.render(form={"name": "", "text": "", "score": ""}, errors=[]))
Вывод
<h1>Оставить отзыв</h1>
<ul class="errors">
  <li>Отзыв короче 10 символов</li>
</ul>
<form action="/review" method="post">
  <input name="name" value="Игорь">
  <textarea name="text">Кофе холодный</textarea>
  <input name="score" value="2">
  <button type="submit">Отправить</button>
</form>

<h1>Оставить отзыв</h1>
<p>Спасибо, отзыв принят!</p>
<form action="/review" method="post">
  <input name="name" value="">
  <textarea name="text"></textarea>
  <input name="score" value="">
  <button type="submit">Отправить</button>
</form>

Пользователь Игорь не потерял ни символа: ошибки списком сверху, введённое подставлено обратно через value="{{ form.name }}". Когда список errors пуст, блок с ошибками исчезает и появляется строка благодарности. Во Flask этот шаблон рендерится тем же render_template из урока 3, а словарь form собирается из request.form одной строкой: form = request.form.to_dict().

flash и redirect: после POST нужен переадресация

Разберём ситуацию: гость отправил отзыв, страница обновилась — и он нажал F5. Браузер повторно отправит POST с теми же данными, и отзыв задублируется. Стандартное решение называют паттерном Post/Redirect/Get: обработали POST — сделайте redirect. Тогда в истории браузера останется GET-адрес, и обновление страницы безопасно.

А как сообщить гостю, что всё получилось? Функция flash кладёт сообщение в подписанную сессию на один показ: следующая страница выведет его и забудет. Для работы флеша приложению нужен secret_key — ключ, которым Flask подписывает куки.

app.py — форма обратной связи целиком
from flask import (
    Flask, request, redirect, flash,
    render_template, get_flashed_messages,
)

app = Flask(__name__)
app.secret_key = "zamenite-na-sluchajnuyu-stroku"

@app.route("/review", methods=["GET", "POST"])
def review():
    if request.method == "POST":
        name = request.form.get("name", "").strip() or "Аноним"
        text = request.form.get("text", "").strip()
        score = request.form.get("score", "")

        errors = []
        if len(text) < 10:
            errors.append("Отзыв короче 10 символов")
        if score not in {"1", "2", "3", "4", "5"}:
            errors.append("Оценка должна быть от 1 до 5")

        if errors:
            for e in errors:
                flash(e)
        else:
            flash(f"Спасибо, {name}! Отзыв принят")
            # пока отзыв просто принимается - в уроке 5 сохраним его в базу
        return redirect("/review")

    return render_template("review.html",
                           messages=get_flashed_messages())

if __name__ == "__main__":
    app.run()
Вывод
$ curl -i -X POST -d "name=Анна&text=Лучший раф в городе&score=5" http://127.0.0.1:5000/review
HTTP/1.1 302 FOUND
Location: http://127.0.0.1:5000/review
Content-Type: text/html; charset=utf-8
Сервер в браузере не запустить — это реальный код с ожидаемым результатом. curl -i печатает заголовки: статус 302 FOUND и адрес Location, куда браузер немедленно отправится за страницей с флеш-сообщением.
templates/review.html — вывод флеш-сообщений
{% for m in messages %}
  <p class="flash">{{ m }}</p>
{% endfor %}

<form action="/review" method="post">
  <input name="name" placeholder="Ваше имя">
  <textarea name="text" rows="4" cols="40" placeholder="Отзыв"></textarea>
  <select name="score">
    <option value="5">5</option>
    <option value="4">4</option>
    <option value="3">3</option>
  </select>
  <button type="submit">Отправить</button>
</form>
Шаблон лежит в templates/review.html рядом с app.py. Цикл по messages выводит флеш-сообщения; список приходит из get_flashed_messages() во view-функции.

Флеш-сообщения живут в подписанной сессии, поэтому curl без cookie-хранилища их не увидит — эффект проверяйте в браузере. Как устроена сессия изнутри, почему куки подписывают и что вообще хранить в session — тема урока 6.

CSRF и WTForms: когда форм становится много

Одна форма — легко руками. Пять форм с повторяющейся валидацией, подстановкой значений и защитой — уже рутина, в которой легко ошибиться. Сообщество собрало её в расширение Flask-WTF поверх библиотеки WTForms: форма становится Python-классом, валидация — декларативным списком правил, а защита включается сама.

Сначала о защите. CSRF (Cross-Site Request Forgery, подделка межсайтовых запросов) — атака, при которой злоумышленник прячет на своём сайте форму, отправляющую POST на ваш сайт от имени залогиненного пользователя: браузер честно приложит его куки. Лекарство — скрытое поле с токеном, который выдаёт ваш сервер и проверяет при приёме. Flask-WTF вставляет токен за вас: в шаблоне пишется {{ form.hidden_tag() }}, и каждая отправка проверяется автоматически.

forms.py — та же форма как класс Flask-WTF
from flask_wtf import FlaskForm
from wtforms import (StringField, TextAreaField,
                     SelectField, SubmitField)
from wtforms.validators import DataRequired, Length

class ReviewForm(FlaskForm):
    name = StringField(
        "Имя",
        validators=[DataRequired(message="Укажите имя")],
    )
    text = TextAreaField(
        "Отзыв",
        validators=[Length(min=10, message="Отзыв короче 10 символов")],
    )
    score = SelectField(
        "Оценка", choices=[("5", "5"), ("4", "4"), ("3", "3")]
    )
    submit = SubmitField("Отправить")
Пакет flask-wtf ставится отдельно: pip install flask-wtf. В браузерной песочнице его нет — блок приведён как реальный код с ожидаемым поведением: form.validate_on_submit() вернёт False и заполнит form.errors.

Во view-функции это выглядит лаконично: form = ReviewForm(), затем if form.validate_on_submit(): — сохранить данные и redirect. Ошибки лежат в form.errors словарём «поле: список сообщений». Мой практический ориентир: одну-две формы спокойно держите руками — вы только что написали всю механику и понимаете, как она устроена изнутри. От трёх форм и выше переходите на Flask-WTF: меньше повторов, CSRF из коробки, и ошибки валидации стандартизированы.

Итоги урока: сайт научился слушать

  • Форма отправляет пары «name = значение»; без атрибута name поле молча теряется
  • Поля POST-формы лежат в request.form, параметры URL — в request.args; оба читаются через .get()
  • Маршрут, принимающий форму, объявляется с methods=["GET", "POST"], иначе — 405
  • Валидация живёт на сервере: strip() для имён, len() для текстов, сравнение со множеством для оценок
  • После успешного POST — redirect (Post/Redirect/Get), а результат гостю — через flash-сообщение
  • С ростом числа форм — Flask-WTF: формы-классы, валидаторы и CSRF-токен из коробки

Но отзыва из этого урока всё ещё нет: мы его приняли, поблагодарили автора и забыли. Перезапуск сервера — и история пуста, потому что переменные живут только внутри процесса. В уроке 5 отзыв получит постоянную прописку: подключим SQLite, опишем модель SQLAlchemy и соберём гостевую книгу, которая помнит всех гостей. До финального проекта ещё шесть уроков; весь курс — самоучитель Flask.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

form = {"name": "Оля"}
name = form.get("name", "Аноним").strip() or "Аноним"
score = form.get("score", "5")
print(name, score)
from jinja2 import Template

t = Template(
    "{% if errors %}Ошибок: {{ errors | length }}"
    "{% else %}Всё хорошо{% endif %}"
)
print(t.render(errors=[]))
Проверь себя
0 / 5

1. Какой атрибут HTML-поля делает его значение видимым для request.form?

2. Что вернёт request.form.get("phone"), если поля phone в форме не было?

3. Куда Flask кладёт параметры из адреса /menu?sort=price?

4. Маршрут объявлен как @app.route("/review"), а форма шлёт POST. Что получит гость?

5. Зачем после успешной обработки POST делают redirect?

Карточки терминов
Запомнено: 0 / 6
Практика

Напишите функцию validate(form), которая принимает словарь полей формы и возвращает список ошибок: имя пустое (после strip) — «Укажите имя»; отзыв короче 10 символов — «Отзыв слишком короткий»; оценка не из набора 1..5 — «Оценка от 1 до 5». Проверьте форму с тремя нарушениями.

practice.py
Вопросы и ответы по уроку

Как принять данные из HTML-формы во Flask?

У полей формы должны быть атрибуты name, форма шлёт method="post", а маршрут объявляется с methods=["GET", "POST"]. Данные читаются во view-функции через request.form.get("имя_поля", "запас"). Сравнивайте request.method с "POST", чтобы отличить приём формы от её показа.

Чем request.form отличается от request.args?

request.form — поля из тела POST-запроса (отзывы, регистрация, пароли). request.args — параметры из URL после вопросительного знака: ?sort=price&page=2 (поиск, фильтры, номера страниц). Оба читаются одинаково — методом .get() с запасным значением.

Почему возникает ошибка 405 Method Not Allowed и как её исправить?

Форма отправляет POST, а маршрут объявлен без разрешения этого метода: @app.route("/review") по умолчанию отвечает только на GET. Добавьте параметр methods=["GET", "POST"] к декоратору — и POST начнёт доходить до view-функции.

Стоит ли использовать WTForms или хватит валидации руками?

Для одной-двух форм ручная валидация прозрачнее и вы понимаете каждый шаг. Когда форм становится три и больше, Flask-WTF экономит время: формы описываются классами, ошибки собираются в form.errors автоматически, а CSRF-токен вставляется одной строкой form.hidden_tag().

Почему request.form["phone"] падает с BadRequestKeyError?

Поля phone в отправленной форме не было: пользователь удалил его из HTML, открыл старую версию страницы или послал запрос скриптом. request.form — внешние данные, и правила обычного словаря Python на них не действуют: берите request.form.get("phone", "") с запасным значением.

Понравился урок? Сошлитесь на него

«Атрибуты HTML — удобство для гостя, серверная валидация — защита бэкенда.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по Flask

В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Flask: 20 задач