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

Django: создание заметки через ModelForm

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Проверить пользовательский ввод и назначить автора на сервере.

Входное состояние: Определены автор и выборки доступа. Выходное состояние: Автор создаёт собственный черновик с проверенной формой и обложкой. Эта глава продолжает «Редакционный стол» — небольшой сайт заметок. Нужны основы Python, HTML и устройство HTTP-запроса. Используем Python 3.12 и Django 5.2.18 LTS; номер ветки выбран для этой серии. Полный каталог lesson-14 в архиве содержит собственные настройки, шаблоны и историю миграций. При самостоятельном прохождении выберите один снимок и отдельную локальную базу; смешивание файлов разных глав меняет договор примера.

Исходники подготовлены редакционным чтением. Зависимости, миграции, сервер и команды работы с данными здесь не выполнялись. Все описанные ответы и изменения являются ожидаемыми, example_execution: not_run.

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

Начнём с явного списка fields. В него входят title, slug, body и cover. Поля author и status отсутствуют намеренно: автор определяется действующей сессией, а новая заметка становится черновиком. Если клиент самостоятельно пришлёт дополнительные поля в POST, они не должны изменить эту договорённость. Автоматический выбор всех полей модели здесь сделал бы расширение схемы неожиданным расширением пользовательских полномочий.

Связанная и несвязанная форма

При первом GET создаётся несвязанная форма. У неё есть поля, подписи и ограничения, но ещё нет набора отправленных значений. При POST передаём request.POST и request.FILES. Объект становится связанным с входными данными. После is_valid() он содержит либо очищенные значения, либо ошибки, которые шаблон покажет рядом с формой. Не считайте любой POST успешным только потому, что браузер отправил все видимые элементы.

В clean_title убираем внешние пробелы и проверяем минимальную длину в восемь символов. Это простое правило редакции, а не универсальное определение хорошего заголовка. Проверка на сервере нужна даже тогда, когда браузер предлагает своё ограничение. Запрос может прийти из другого клиента, а изменение HTML в инструментах браузера не меняет правила сервера. Сообщение должно объяснять человеку, какое значение требуется исправить.

Для slug важны ограничения поля модели, включая уникальность. ModelForm использует соответствующую проверку модели, поэтому ожидается понятная ошибка для занятого адреса до обычного сохранения. Между проверкой и записью остаётся возможная гонка двух запросов. Окончательный запрет дублирования обеспечивает ограничение базы. В этой локальной серии мы не реализуем специальное повторение конкурентной операции; наличие такой границы не стоит скрывать обещанием абсолютной защиты одной формы.

Обложка имеет дополнительное ограничение размера: два мегабайта. Поле изображения проверяет содержимое с помощью Pillow, а не только расширение имени. При этом успешная проверка изображения не превращает любые пользовательские файлы в безопасный источник активного содержимого. Хранение и раздача медиа требуют отдельной политики. Учебное приложение не принимает SVG как обложку и не смешивает пользовательские файлы с исходными скриптами сайта.

От проверки к сохранению

После корректной проверки вызываем form.save(commit=False). Получаем экземпляр Note, заполненный разрешёнными полями, но ещё не записанный в базу. Затем присваиваем author=request.user и status=Note.Status.DRAFT, после чего сохраняем объект. commit=False не является отменой всех проверок: форма уже проверена, а пауза перед записью нужна, чтобы добавить значения, которые не выбирает посетитель.

В нашей форме нет связей многие ко многим. Поэтому дополнительного save_m2m() здесь не требуется. Если в будущем появятся теги как ManyToManyField и сохранение останется двухэтапным, их запись потребует отдельного внимания. Не добавляйте вызов ради ритуала, но помните причину его появления: связанные записи могут нуждаться в ключе уже сохранённого объекта. Это отличается от обычного присваивания внешнего ключа автора.

После успешного сохранения представление перенаправляет на карточку по фактическому slug объекта. Следующий запрос является GET. Такая последовательность снижает риск повторной отправки при обычном обновлении страницы результата. Она не обеспечивает идемпотентность двух намеренно отправленных POST и не заменяет уникальность slug. При ошибке остаёмся на странице формы и возвращаем тот же связанный объект, чтобы человек сохранил введённый текст и увидел замечания.

Шаблон использует method="post", токен CSRF и enctype="multipart/form-data". Последняя настройка нужна для передачи файла. Без неё текстовые поля могут прийти, а request.FILES окажется пустым; проблема не в ImageField и не в размере обложки. Токен CSRF проверяет происхождение запроса в установленной модели защиты Django. Он не определяет автора: это делает сессия и серверное присваивание.

Что ожидается в интерфейсе

Вошедший автор открывает notes:new, заполняет форму и отправляет её. При коротком заголовке ожидается повторное отображение формы с объяснением. При корректных значениях ожидается перенаправление к собственному черновику. В публичном списке новая заметка пока не появляется, поскольку её статус явно установлен в draft. В личном списке она должна присутствовать благодаря связи с текущим пользователем.

Анонимный запрос должен пройти через требование входа до обработки формы. Скрытая ссылка создания в шапке удобна для посетителя, но не отменяет проверку в представлении. Прямой адрес известен любому человеку. Если вы проверяете только видимость кнопки, серверный обработчик остаётся самостоятельной точкой входа. В нашем снимке login_required и назначение владельца стоят в одном месте, поэтому путь от личности до новой записи можно проследить.

Рассмотрите отказ файлового хранилища после того, как форма прошла проверки. Корректные значения не гарантируют наличие места на диске и возможность записи в каталог. Сохранение базы и файла также не является одной простой транзакцией для всех хранилищ. Эта серия объясняет базовую локальную загрузку, а условия эксплуатации обсуждает в заключении. Не заменяйте такую ошибку сообщением «неверный заголовок»: причина находится на другой границе.

Для самостоятельной работы уберите обложку и опишите ожидаемую запись; затем отправьте имя автора как дополнительное поле и объясните, почему оно не должно управлять владельцем. Наконец, найдите в представлении строку, которая делает заметку черновиком. Эти три случая помогают отделить необязательные данные, недоверенное дополнение запроса и обязательное серверное правило. В следующей главе тот же механизм формы станет редактированием уже существующего собственного объекта.

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные файлы находятся в том же снимке архива; переносите весь каталог при выборе исходного состояния.

notes/forms.py

from django import forms
from .models import Note

class NoteForm(forms.ModelForm):
    class Meta:
        model = Note
        fields = ("title", "slug", "body", "cover")
        widgets = {"body": forms.Textarea(attrs={"rows": 8})}

    def clean_title(self):
        title = self.cleaned_data["title"].strip()
        if len(title) < 8:
            raise forms.ValidationError("Заголовок должен содержать не менее восьми символов.")
        return title

    def clean_slug(self):
        slug = self.cleaned_data["slug"]
        if slug in {"mine", "new"}:
            raise forms.ValidationError("Этот адрес зарезервирован страницей приложения.")
        return slug

    def clean_cover(self):
        cover = self.cleaned_data.get("cover")
        if cover and getattr(cover, "size", 0) > 2 * 1024 * 1024:
            raise forms.ValidationError("Обложка должна быть не больше 2 МиБ.")
        return cover

notes/views.py

from django.shortcuts import get_object_or_404, render
from .models import Note

def note_list(request):
    notes = Note.objects.filter(status=Note.Status.PUBLISHED).order_by("-created_at", "-pk")
    return render(request, "notes/list.html", {"notes": notes})

def note_detail(request, slug):
    from django.db.models import Q
    visible = Q(status=Note.Status.PUBLISHED)
    if request.user.is_authenticated:
        visible |= Q(author=request.user)
    note = get_object_or_404(Note.objects.filter(visible), slug=slug)
    return render(request, "notes/detail.html", {"note": note})

from django.contrib.auth.decorators import login_required

@login_required
def my_notes(request):
    notes = Note.objects.filter(author=request.user).order_by("-created_at", "-pk")
    return render(request, "notes/list.html", {"notes": notes})

from .forms import NoteForm
from django.shortcuts import redirect

@login_required
def note_create(request):
    if request.method == "POST":
        form = NoteForm(request.POST, request.FILES)
        if form.is_valid():
            note = form.save(commit=False)
            note.author = request.user
            note.status = Note.Status.DRAFT
            note.save()
            return redirect("notes:detail", slug=note.slug)
    else:
        form = NoteForm()
    return render(request, "notes/form.html", {"form": form, "heading": "Новая заметка"})

notes/templates/notes/form.html

{% extends "base.html" %}
{% block content %}
<h1>{{ heading }}</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}<button>Сохранить черновик</button>
</form>
{% endblock %}

notes/urls.py

from django.urls import path
from . import views
app_name = "notes"
urlpatterns = [path("",
    views.note_list,
    name="list"),
    path("new/",
    views.note_create,
    name="create"),
    path("mine/",
    views.my_notes,
    name="mine"),
    path("<slug:slug>/",
    views.note_detail,
    name="detail")]

Документация и исходники

Механизм этой главы описан в официальной документации Django 5.2. При самостоятельной проверке сначала сопоставьте маршрут, представление и состояние данных, затем сравните результат с указанным ожидаемым поведением. Архив учебного проекта содержит все шестнадцать самостоятельных этапов и договор эксплуатации.