Формы в 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" запускает отправку. Вот форма отзыва для нашей кофейни:
<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>
Гость вводит имя, пишет отзыв, выбирает оценку и жмёт кнопку. Браузер собирает пары «имя поля = значение» в строку вида name=Анна&text=...&score=5 и отправляет её POST-запросом на адрес /review. Дальше эстафета переходит вашему Python-коду.
GET или POST: два способа передать данные
У HTTP-запроса есть метод — глагол, который сообщает серверу намерение браузера. GET значит «дай страницу», POST — «прими данные». Одни и те же поля можно отправить обоими методами, но ведут они себя по-разному, и выбор — не вкусовщина, а правило семантики и безопасности:
| GET | POST | |
|---|---|---|
| Где лежат данные | в самом URL: /menu?sort=price | в теле запроса, в адресе не видны |
| Ограничение объёма | пара тысяч символов | практически нет |
| Обновление страницы (F5) | безопасно повторить | браузер предложит отправить данные заново |
| Для чего годится | поиск, фильтры, страницы каталога | отзывы, регистрация, пароли, файлы |
| Ссылка и закладка | параметры сохраняются в URL | данных в URL нет, закладка бессмысленна |
Правило одной строкой: читаете данные — GET, меняете что-то на сервере (сохраняете отзыв, создаёте пользователя) — POST. Пароль в GET-параметре — готовая уязвимость: он останется в истории браузера, логах сервера и, если очень не повезёт, в кэше промежуточных прокси.
request.form: принимаем POST во view-функции
Во Flask всё, что прислал браузер, доступно через объект request. Импортируйте его из flask — и данные формы окажутся в request.form: это словареподобный объект, где ключи — значения атрибутов name, а значения — то, что ввёл гость. Ещё маршрут должен разрешить POST явно: без указания методов Flask отвечает только на GET.
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: Лучший раф в городе
Ветвление if request.method == "POST" — классический приём: один и тот же маршрут и рисует форму (GET), и принимает её (POST). Метод .get() со вторым аргументом возвращает значение по умолчанию, если поля нет, — форма без имени не уронит сайт.
request.args: а это данные из URL
Пары вида ?sort=price&page=2 после вопросительного знака называют строкой запроса (query string). Flask кладёт их не в request.form, а в request.args — механика чтения та же самая:
@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
Итого четыре места, где прячутся данные запроса: request.form — поля формы из POST, request.args — параметры из URL, request.values — объединение обоих, request.json — JSON в теле для API. Для HTML-форм почти всегда хватает первых двух.
Ошибка 405: маршрут есть, метод не разрешён
Забудете methods=["POST"] — POST-запрос упадёт с ошибкой, которая сначала выглядит загадочно. Проверим на приложении, где маршрут объявлен без методов:
$ 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>
Валидация руками: не верь данным от браузера
Данные формы может прислать кто угодно: 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 подписывает куки.
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
{% 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>
Флеш-сообщения живут в подписанной сессии, поэтому curl без cookie-хранилища их не увидит — эффект проверяйте в браузере. Как устроена сессия изнутри, почему куки подписывают и что вообще хранить в session — тема урока 6.
CSRF и WTForms: когда форм становится много
Одна форма — легко руками. Пять форм с повторяющейся валидацией, подстановкой значений и защитой — уже рутина, в которой легко ошибиться. Сообщество собрало её в расширение Flask-WTF поверх библиотеки WTForms: форма становится Python-классом, валидация — декларативным списком правил, а защита включается сама.
Сначала о защите. CSRF (Cross-Site Request Forgery, подделка межсайтовых запросов) — атака, при которой злоумышленник прячет на своём сайте форму, отправляющую POST на ваш сайт от имени залогиненного пользователя: браузер честно приложит его куки. Лекарство — скрытое поле с токеном, который выдаёт ваш сервер и проверяет при приёме. Flask-WTF вставляет токен за вас: в шаблоне пишется {{ form.hidden_tag() }}, и каждая отправка проверяется автоматически.
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("Отправить")
Во 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=[]))
1. Какой атрибут HTML-поля делает его значение видимым для request.form?
2. Что вернёт request.form.get("phone"), если поля phone в форме не было?
3. Куда Flask кладёт параметры из адреса /menu?sort=price?
4. Маршрут объявлен как @app.route("/review"), а форма шлёт POST. Что получит гость?
5. Зачем после успешной обработки POST делают redirect?
Напишите функцию validate(form), которая принимает словарь полей формы и возвращает список ошибок: имя пустое (после strip) — «Укажите имя»; отзыв короче 10 символов — «Отзыв слишком короткий»; оценка не из набора 1..5 — «Оценка от 1 до 5». Проверьте форму с тремя нарушениями.
Как принять данные из 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 3
Шаблоны Jinja2: HTML-страницы с данными из Python
Третий урок курса Flask: шаблоны Jinja2 — переменные, циклы, фильтры и наследование base.html. Отличие этого урока: все примеры Jinja2 можно отрендерить прямо на странице.
Flask · Урок 5
База данных в Flask: SQLite и Flask-SQLAlchemy
Пятый урок курса Flask: данные переживают перезапуск сервера. Подключаем SQLite, описываем модели Flask-SQLAlchemy, проходим CRUD и собираем гостевую книгу, которая помнит всех гостей.
Flask · Урок 6
Сессии, куки и flash-сообщения во Flask: память о пользователе
Шестой урок курса Flask: session как словарь, куки set_cookie, секретный ключ и flash-сообщения. Учим приложение помнить пользователя между запросами.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
requests · Урок 6
POST-запросы в requests: формы data, JSON и загрузка файлов
POST отправляет данные в теле запроса: формы через data, JSON через json=, файлы через files=. Разбираем, чем они отличаются и где теряют данные.
requests post data jsonrequests post python
aiogram · Урок 6
База данных в телеграм-боте: SQLite от первого лица
Даём боту настоящую память: таблица пользователей с chat_id, запись через INSERT OR IGNORE, чтение по chat_id и параметр ? против SQL-инъекций.
телеграм бот база данныхбаза данных для бота
Flask · Урок 13
REST API на Flask: jsonify и методы GET, POST
Тринадцатый урок курса Flask, центральный в расширении: приложение перестаёт отдавать только HTML и начинает говорить JSON. Маршруты с methods, jsonify, статус 201 при создании — и полный цикл POST создал, GET вернул, прямо в браузере.
flask rest api jsonifyflask jsonify пример
Проверьте знания по Flask
В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по Flask: 20 задач