Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Формы и Виджеты
forms_widgets

Формы и Виджеты

Form, ModelForm, валидация, файлы, formsets и виджеты

Формы и виджеты Django

Форма задаёт границу недоверенного ввода: преобразует строки и файлы в типизированные значения, собирает ошибки и только после успешной проверки отдаёт cleaned_data. ModelForm дополнительно связывает этот контракт с моделью.

Материал актуален для Django 5.2 LTS и 6.0.

#Bound и unbound form

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, поэтому проверка не выполняется заново при каждом чтении результата.

#Form описывает входной контракт

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.

#ModelForm фиксирует разрешённые поля

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.

#Создание и many-to-many

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().

#Редактирование требует object-level доступа

@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; скрытой кнопки в шаблоне недостаточно.

#Правильный HTML формы

<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, затем протестируйте поддерживаемые браузеры.

#Custom widget: HTML отдельно, JavaScript отдельно

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 uploaded

View передаёт оба контейнера:

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-файлов при откате или сбое.

#Formset: повтор одного контракта

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() и пропускают уже ошибочные формы.

#Crispy Forms — сторонний renderer

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 не защищает от автоматической отправки формы с вашего сайта.

#Пароли и custom user

Не пишите регистрацию на двух 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.

#Legacy и актуальность

Основной Forms API стабилен. Legacy-код часто содержит fields = "__all__", ручную проверку пароля, inline JavaScript из Widget.render(), CDN без контроля версий и отправку письма от адреса пользователя. Эти конструкции не обязательно удалены из Django, но требуют исправления по безопасности и сопровождению.

Renderer as_p() остаётся рабочим. В современном Django доступны также as_div() и template-based rendering; переписывать старую форму только ради другого wrapper необязательно, если она доступна и протестирована.

#Документация

  • Working with forms
  • Form and field validation
  • ModelForm
  • File uploads
  • Formsets

Далее: Аутентификация: Пользователи