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

Редактирование и удаление своей заметки

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

Учебная задача: Ограничить изменение владельцем и связать ModelForm с существующей записью.

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

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

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

Представление сначала требует входа, затем вызывает get_object_or_404 с двумя условиями: slug из адреса и author=request.user. Это одновременно поиск объекта и ограничение полномочия. Опубликованная заметка другого автора остаётся доступной для чтения, но не входит в разрешённую выборку изменения. Поэтому мы не переиспользуем публичное правило карточки, где статус published давал доступ любому посетителю.

Форма для конкретного экземпляра

При GET создаём NoteForm(instance=note). Начальные значения берутся из выбранной записи. При POST передаём данные, файлы и тот же instance. После корректной проверки save() обновляет эту запись с прежним первичным ключом. Для понимания операции следите за ключом, а не за заголовком: человек может поменять весь видимый текст, но объект остаётся тем же редакционным материалом.

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

После сохранения перенаправляем к карточке, используя новое значение note.slug. Старая строка из входного URL может уже не обозначать эту заметку. Если редирект строить из старого параметра, пользователь получит ожидаемый 404 сразу после успешного изменения адреса. Это важная связь между состоянием объекта и состоянием навигации. В учебном проекте автоматическая история старых адресов не ведётся; для опубликованных материалов редакция должна учитывать внешние ссылки.

Уникальность slug проверяется модельной формой и ограничением базы. Слова mine и new зарезервированы маршрутами личного списка и создания. Поэтому форма дополнительно отклоняет их как адрес заметки. Иначе запись могла бы существовать в базе, но её обычная карточка пересекалась бы с постоянной страницей приложения. Конфликт URL не исчезает от уникальности столбца: таблица и дерево маршрутов проверяют разные свойства.

Обложка остаётся необязательной. Стандартный виджет существующего ImageField показывает имеющееся значение и возможность очистить его. Передача request.FILES нужна и при редактировании: новый файл не придёт через словарь текстовых полей. При этом удаление ссылки из модели и удаление старого файла с диска не следует считать одной автоматической операцией. Пример не обещает очистку файлов, которые больше не используются.

Почему удаление является отдельным действием

Для удаления используем отдельное представление с require_POST и требованием входа. Получаем объект только из заметок текущего автора, вызываем delete() и перенаправляем к личному списку. В карточке находится форма с CSRF-токеном и кнопкой. Обычная ссылка с GET не подходит: переход, предварительная загрузка или сканирование адреса не должны сами уничтожать запись.

Расположение декораторов важно читать вместе с их обязанностями. Один устанавливает необходимость известной личности, другой ограничивает метод. Оба не выбирают владельца записи; это делает условие author=request.user в поиске. Так можно объяснить каждый отказ: анонимность ведёт ко входу, неподходящий метод не выполняет удаление, а чужой либо отсутствующий объект не находится в разрешённом наборе.

Показ кнопок в шаблоне тоже ограничен владельцем. Пользователь не должен видеть действия, которыми не может воспользоваться. Однако это правило интерфейса повторяет уже установленное серверное правило, а не заменяет его. Изменённый HTML или самостоятельный POST не расширяют выборку представления. Для критерия правильности важен прямой запрос к адресу чужой записи, даже если обычная страница не предлагает такую кнопку.

Разбор ожидаемых случаев

Автор открывает свою заметку, меняет заголовок и отправляет форму. Ожидается сохранение той же записи и переход к её карточке. Другой автор открывает адрес редактирования этой заметки напрямую: ожидается 404 из ограниченной выборки. Владелец отправляет GET на адрес удаления: запись не должна исчезнуть. При корректном POST удаления собственная заметка ожидаемо исчезает из личного списка и публичного списка, если ранее была опубликована.

Теперь представьте две вкладки одной заметки. В первой изменён заголовок, во второй остался старый текст. Если обе формы будут отправлены последовательно, более позднее сохранение может перезаписать предыдущие значения. У нас нет поля версии и проверки конкурентного изменения. ModelForm не обеспечивает их автоматически. Такая граница особенно важна для командной редакции: позже можно добавить номер версии или сравнение времени обновления и осмысленный конфликт.

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

Проверьте чтением, что форма создания не принимает instance, а форма редактирования принимает его в обоих ветвлениях GET и POST. Затем проследите, откуда берётся slug для редиректа и где ограничивается автор. Эти точки определяют корректность операции сильнее, чем внешний вид кнопки. Наши ожидаемые результаты опираются на подготовленные исходники; приложение и действия удаления при подготовке не выполнялись.

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

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

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": "Новая заметка"})

from django.views.decorators.http import require_POST

@login_required
def note_edit(request, slug):
    note = get_object_or_404(Note, slug=slug, author=request.user)
    if request.method == "POST":
        form = NoteForm(request.POST, request.FILES, instance=note)
        if form.is_valid():
            form.save()
            return redirect("notes:detail", slug=form.instance.slug)
    else:
        form = NoteForm(instance=note)
    return render(request, "notes/form.html", {"form": form, "heading": "Изменение заметки"})

@login_required
@require_POST
def note_delete(request, slug):
    note = get_object_or_404(Note, slug=slug, author=request.user)
    note.delete()
    return redirect("notes:mine")

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>/edit/",
    views.note_edit,
    name="edit"),
    path("<slug:slug>/delete/", views.note_delete, name="delete"),
    path("<slug:slug>/", views.note_detail, name="detail")]

notes/templates/notes/detail.html

{% extends "base.html" %}
{% block title %}{{ note.title }} · Редакционный стол{% endblock %}
{% block content %}
<article>
<h1>{{ note.title }}</h1>
{% if note.cover %}
<img src="{{ note.cover.url }}" alt="Обложка: {{ note.title }}">
{% endif %}
<p>{{ note.body|linebreaksbr }}</p>
<time datetime="{{ note.created_at|date:'c' }}">{{ note.created_at|date:'d.m.Y' }}</time>
{% if user.is_authenticated and note.author_id == user.id %}
<a href="{% url 'notes:edit' note.slug %}">Изменить</a>
<form action="{% url 'notes:delete' note.slug %}" method="post">
{% csrf_token %}
<button>Удалить заметку</button>
</form>
{% endif %}
</article>
{% endblock %}

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 %}

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

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