Form, ModelForm, валидация, файлы, formsets и виджеты
Форма задаёт границу недоверенного ввода: преобразует строки и файлы в типизированные значения, собирает ошибки и только после успешной проверки отдаёт cleaned_data. ModelForm дополнительно связывает этот контракт с моделью.
Материал актуален для Django 5.2 LTS и 6.0.
Unbound-форма ещё не получала ввод и не показывает ошибки. Bound-форма привязана к данным, даже если POST пуст:
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods
from .forms import ContactForm
@require_http_methods(["GET", "POST"])
def contact(request):
data = request.POST if request.method == "POST" else None
form = ContactForm(data=data)
if request.method == "POST" and form.is_valid():
message = form.save()
return redirect("contact_done", pk=message.pk)
return render(request, "contact/form.html", {"form": form})Запись ContactForm(request.POST or None) популярна, но пустой POST превращает в unbound-форму и скрывает обязательные ошибки. Проверка метода выражает намерение точнее.
is_valid() запускает полный цикл проверки. После успеха используйте только cleaned_data, а не исходный request.POST. Обращение к form.errors тоже запускает validation, поэтому проверка не выполняется заново при каждом чтении результата.
from django import forms
class FeedbackForm(forms.Form):
email = forms.EmailField(label="Email для ответа", max_length=254)
subject = forms.CharField(label="Тема", max_length=120)
message = forms.CharField(
label="Сообщение",
max_length=5000,
widget=forms.Textarea(attrs={"rows": 8}),
)
consent = forms.BooleanField(label="Согласен на обработку данных")Field определяет тип, обязательность, преобразование и validators. Widget определяет HTML-представление. Атрибут required в браузере улучшает UX, но серверная required=True остаётся источником истины.
Для каждого поля Django выполняет преобразование типа, встроенную проверку и validators, затем вызывает clean_<field>(). После полей вызывается Form.clean() для зависимостей между ними.
from django.core.exceptions import ValidationError
class BookingForm(forms.Form):
starts_at = forms.DateTimeField()
ends_at = forms.DateTimeField()
def clean(self):
cleaned_data = super().clean()
starts_at = cleaned_data.get("starts_at")
ends_at = cleaned_data.get("ends_at")
if starts_at and ends_at and ends_at <= starts_at:
self.add_error("ends_at", "Окончание должно быть позже начала")
return cleaned_dataПоля могут отсутствовать в cleaned_data, если их собственная проверка уже завершилась ошибкой. Поэтому межполевой код использует .get().
Повторяемое правило уровня одного значения оформляют validator, а не копируют по формам:
def validate_even(value):
if value % 2:
raise ValidationError("Введите чётное число", code="odd")Код ошибки полезен тестам и переопределению сообщений. Проверка «такой email ещё не занят» на уровне формы улучшает обратную связь, но не защищает от гонки двух запросов. Уникальность обеспечивает constraint базы, а view обрабатывает возможный IntegrityError.
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "body", "category", "tags"]
widgets = {
"body": forms.Textarea(attrs={"rows": 12}),
}Не используйте fields = "__all__" в публичной форме. После миграции новое служебное поле может автоматически попасть в HTML и mass assignment. Явный список — часть security review.
ModelForm запускает проверки формы, model validation и uniqueness, но база остаётся последней защитой. Собственные переопределения clean() должны вызывать super().clean(), иначе можно отключить часть model/unique validation.
from django.contrib.auth.decorators import login_required
@login_required
def post_create(request):
data = request.POST if request.method == "POST" else None
form = PostForm(data=data)
if request.method == "POST" and form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m()
return redirect("blog:post_detail", slug=post.slug)
return render(request, "blog/post_form.html", {"form": form})При commit=False объект ещё не имеет сохранённого primary key, поэтому many-to-many данные записываются отдельным save_m2m() после post.save().
@login_required
def post_edit(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
data = request.POST if request.method == "POST" else None
form = PostForm(data=data, instance=post)
if request.method == "POST" and form.is_valid():
post = form.save()
return redirect("blog:post_detail", slug=post.slug)
return render(request, "blog/post_form.html", {"form": form})Передача instance не авторизует пользователя. Объект выбирается из разрешённого QuerySet; скрытой кнопки в шаблоне недостаточно.
<form method="post" novalidate>
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div>
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<p>{{ field.help_text }}</p>{% endif %}
{{ field.errors }}
</div>
{% endfor %}
<button type="submit">Сохранить</button>
</form>novalidate отключает browser validation для демонстрации серверных ошибок; в обычном продукте её часто оставляют включённой как дополнительный UX-слой. CSRF-токен обязателен для same-origin HTML POST при стандартной защите.
Свяжите help/error с input через стандартный renderer Django или собственный доступный шаблон: одного красного цвета недостаточно, фокус после ошибки должен попадать к понятному сообщению.
class EventForm(forms.Form):
starts_on = forms.DateField(
input_formats=["%Y-%m-%d"],
widget=forms.DateInput(
format="%Y-%m-%d",
attrs={"type": "date"},
),
)
seats = forms.IntegerField(
min_value=1,
widget=forms.NumberInput(attrs={"inputmode": "numeric"}),
)
tariff = forms.ChoiceField(
choices=[("base", "Базовый"), ("pro", "Профессиональный")],
widget=forms.RadioSelect,
)Choices принадлежат ChoiceField, не виджету. Для decimal используется forms.DecimalField с обычным forms.NumberInput; класса DecimalNumberInput в Django нет.
HTML date input передаёт ISO-дату, но отображение зависит от браузера и locale. Укажите согласованные format и input_formats, затем протестируйте поддерживаемые браузеры.
Widget можно снабдить template_name и Media, но не стоит дописывать <script> строкой в render(). Такой код трудно экранировать, он конфликтует с CSP и ломается при нескольких экземплярах formset.
class AutocompleteSelect(forms.Select):
template_name = "widgets/autocomplete_select.html"
class Media:
css = {"all": ("widgets/autocomplete.css",)}
js = ("widgets/autocomplete.js",)Скрипт должен инициализировать элементы по data-атрибуту и корректно работать при динамическом добавлении formset. Версионированные локальные assets упрощают CSP и supply-chain контроль. Если нужен Select2 для выбора существующих объектов, базой будет Select/SelectMultiple и подходящее choice-поле, а не TextInput, притворяющийся select.
Форма:
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ["title", "file"]
def clean_file(self):
uploaded = self.cleaned_data["file"]
if uploaded.size > 10 * 1024 * 1024:
raise ValidationError("Файл больше 10 МБ", code="too_large")
return uploadedView передаёт оба контейнера:
data = request.POST if request.method == "POST" else None
files = request.FILES if request.method == "POST" else None
form = DocumentForm(data=data, files=files)Шаблон использует multipart encoding:
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">Загрузить</button>
</form>Storage сохраняет файл. Но расширение и присланный Content-Type нельзя считать доказательством формата. Для изображений ImageField использует Pillow для базовой проверки, однако production-политика может также ограничивать пиксели, перекодировать raster, запрещать активные форматы вроде SVG, сканировать malware и выдавать пользовательские файлы с отдельного домена или как attachment.
Не используйте исходное имя как доверенный путь. Генерируйте storage key и не позволяйте .. или разделителям выбирать каталог. Ограничьте размер запроса на reverse proxy и приложении: проверка формы начинается после приёма upload. База и object storage не участвуют в одной транзакции, поэтому продумайте очистку orphan-файлов при откате или сбое.
from django.forms import formset_factory
ItemFormSet = formset_factory(
ItemForm,
extra=1,
max_num=20,
validate_max=True,
can_delete=True,
)Шаблон обязан вывести management_form:
{{ formset.management_form }}
{% for form in formset %}
{{ form.as_div }}
{% endfor %}Сохраняйте model/inline formset внутри transaction.atomic(), если набор должен измениться целиком. Ограничивайте max_num с validate_max=True, проверяйте право на каждый существующий instance и не доверяйте id из management data. Для правил между строками переопределяют BaseFormSet.clean() и пропускают уже ошибочные формы.
django-crispy-forms координирует layout, а конкретный UI обычно приходит отдельным template pack, например crispy-bootstrap5 или crispy-tailwind. Совместимость пакета и Django проверяют перед обновлением.
Есть два корректных стиля:
{% load crispy_forms_tags %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit">Отправить</button>
</form>Или helper управляет всем тегом формы:
{% load crispy_forms_tags %}
{% crispy form %}Выражение {{ form.helper }} не рендерит форму. Не создавайте две вложенные <form>: если helper генерирует form tag, внешний тег не нужен. Для file upload helper или шаблон должен сохранить multipart/form-data.
Сторонний renderer экономит разметку, но не заменяет server-side validation, object authorization, доступность и тестирование ошибок.
Адрес пользователя нельзя ставить в from_email: SPF/DMARC домена не подтверждают право сервера отправлять от его имени. Используйте верифицированного отправителя проекта и reply_to:
from django.conf import settings
from django.core.mail import EmailMessage
email = EmailMessage(
subject=form.cleaned_data["subject"],
body=form.cleaned_data["message"],
from_email=settings.DEFAULT_FROM_EMAIL,
to=[settings.SUPPORT_EMAIL],
reply_to=[form.cleaned_data["email"]],
)Отправку разумно вынести из HTTP-request в задачу после сохранения сообщения и успешного commit. Добавьте rate limit и anti-spam; CSRF не защищает от автоматической отправки формы с вашего сайта.
Не пишите регистрацию на двух CharField с собственной проверкой «8 символов и одна цифра». UserCreationForm использует настроенные AUTH_PASSWORD_VALIDATORS и корректно хэширует пароль. Формы аутентификации и смены пароля уже учитывают важные детали Django auth.
Если проект использует custom user, не импортируйте django.contrib.auth.models.User в прикладных формах. Используйте готовые auth forms либо get_user_model() и адаптируйте Meta.fields к контракту своей модели. Уникальность email должна быть выражена constraint базы, если продукт действительно использует email как уникальный идентификатор.
Logout изменяет состояние и выполняется POST-формой с CSRF, а не ссылкой GET.
Основной Forms API стабилен. Legacy-код часто содержит fields = "__all__", ручную проверку пароля, inline JavaScript из Widget.render(), CDN без контроля версий и отправку письма от адреса пользователя. Эти конструкции не обязательно удалены из Django, но требуют исправления по безопасности и сопровождению.
Renderer as_p() остаётся рабочим. В современном Django доступны также as_div() и template-based rendering; переписывать старую форму только ради другого wrapper необязательно, если она доступна и протестирована.
Далее: Аутентификация: Пользователи