Django: slug и отдельная карточка заметки
Оглавление курса · Скачать все состояния проекта.
Учебная задача: Создать адрес карточки и безопасно добавить уникальный slug к существующим строкам.
Входное состояние: Снимок lesson-07: модель заметки без адресного поля. Выходное состояние: Уникальный slug, трёхэтапная миграция старых записей, адрес карточки и 404 при отсутствии. Эта глава продолжает «Редакционный стол» — небольшой сайт заметок. Нужны основы Python, HTML и устройство HTTP-запроса. Используем Python 3.12 и Django 5.2.18 LTS; номер ветки выбран для этой серии. Полный каталог lesson-08 в архиве содержит собственные настройки, шаблоны и историю миграций. При самостоятельном прохождении выберите один снимок и отдельную локальную базу; смешивание файлов разных глав меняет договор примера.
Исходники подготовлены редакционным чтением. Зависимости, миграции, сервер и команды работы с данными здесь не выполнялись. Все описанные ответы и изменения являются ожидаемыми, example_execution: not_run.
Список становится полезнее, когда каждой заметке соответствует отдельный адрес. Для этого введём slug — короткое строковое значение, пригодное для URL. В учебном наборе используем note-1 и note-2; редактор позже сможет выбирать более описательные имена. Slug не заменяет заголовок и первичный ключ. Он отвечает за публичный путь карточки, а не за всё устройство записи. Такое разделение позволяет менять заголовок без немедленного изменения внутреннего идентификатора.
Поле SlugField объявлено уникальным. Если два объекта имеют один и тот же адрес, представление не сможет однозначно выбрать карточку. Уникальность должна обеспечиваться на уровне данных, а не только удачным набором исходных значений. Однако таблица может уже содержать несколько строк. Поэтому прежде чем добавлять обязательное уникальное поле, надо определить способ заполнения существующих записей. Именно этой части задачи уделим внимание, а не ограничимся новым именем папки.
Миграция с существующими заметками
Первой операцией добавляем допускающий NULL столбец. На этом промежуточном шаге прежние записи остаются допустимыми. Затем RunPython получает историческую модель через apps.get_model. Она описывает состояние модели в данном месте цепочки миграций. Не импортируем текущий класс из notes.models: через несколько будущих глав он изменится, а старая миграция должна по-прежнему уметь работать со своим историческим состоянием.
Для каждой строки присваиваем note-{pk}. Первичный ключ уже уникален, поэтому выбранный шаблон даёт разные значения. Это техническое заполнение старых адресов, а не автоматический редакторский выбор заголовка. В конце AlterField делает slug обязательным и уникальным. Такой переход позволяет объяснить, откуда каждое старое значение взялось. Одинаковый default="note" при добавлении уникального поля не подошёл бы, потому что несколько строк получили бы один адрес.
Миграция использует псевдоним соединения из schema_editor. Так чтение и сохранение проходят в той базе, для которой применяются операции. В нашем проекте база одна, но пример сохраняет правильный смысл при явном выборе подключения. Обратная операция заполнения задана как noop: при обратном удалении поля само значение всё равно исчезнет. Мы не обещаем восстановить придуманные адреса как отдельную историческую информацию после полного отката.
Маршрут карточки
Конвертер <slug:slug> извлекает подходящую часть пути и передаёт её представлению именованным аргументом. Имя после двоеточия должно совпасть с параметром функции. Маршрут не проверяет существование записи; он лишь проверяет форму пути. Значение unknown-note может быть синтаксически допустимым и при этом отсутствовать в базе. Поэтому в представлении используем get_object_or_404 и явно различаем совпадение маршрута и нахождение объекта.
Ссылка в списке строится по имени notes:detail и значению note.slug. В шаблоне не собираем адрес вручную из строк. Если поменяется префикс приложения, именованное построение сохранит правильную связь. При создании карточки следите за уникальностью постоянного адреса: изменение slug делает старую ссылку недействующей, если отдельно не добавлена система перенаправлений. В этом учебном проекте такую систему не вводим и рекомендуем сохранять slug опубликованной записи.
get_object_or_404 при отсутствии объекта формирует HTTP-ошибку, которую Django показывает подходящим ответом. Это отличается от пустого успешного документа с заголовком «Ничего нет». Код состояния сообщает браузеру и другим клиентам, что ресурс не найден. Не перехватывайте любое исключение базы и не превращайте его в 404: неисправная база и отсутствие одной записи — разные причины. Здесь преобразуется только обычная ситуация отсутствующего объекта.
Карточка в общем документе
Шаблон карточки наследует base.html и переопределяет название вкладки. В основной области показывает заголовок, тело и время. Фильтр linebreaksbr превращает переносы текста в HTML-переносы, сохраняя обычную работу экранирования. Это удобнее, чем доверять тексту как готовому HTML. Читатель заметки видит разделённые строки, а произвольный тег в исходном содержимом не должен становиться исполняемой инструкцией браузера.
У time есть машинное значение в атрибуте datetime и читабельное отображение даты. Эти формы решают разные задачи. Значение даты не надо хранить в модели уже отформатированным для русского экрана: это связало бы хранение с одной локалью. Шаблон выбирает представление, модель хранит момент. Такая граница пригодится, когда редакция захочет показывать дату с временем или на другом языке.
Ожидаемый путь /notes/note-1/ открывает «Расписание интервью», а неизвестный slug даёт 404. В Admin prepopulated_fields предлагает значение по заголовку, однако не отменяет проверку уникальности. Для самостоятельного задания придумайте две заметки с одним slug и объясните, на каком уровне возникнет конфликт. Затем рассмотрите, что будет со старой ссылкой при переименовании slug. В следующей главе карточка получит необязательную обложку, сохранив правильное поведение без файла.
Разбор связи файлов
Slug является частью адреса, а первичный ключ остаётся идентификатором строки. Эти значения могут изменяться по разным правилам. Адрес удобен человеку, но изменение slug опубликованного материала разрывает старую внешнюю ссылку, если проект не сохраняет перенаправление. В этой серии истории адресов нет, поэтому редактирование slug следует делать осмысленно.
Миграция использует историческую модель через apps.get_model, а не импорт текущего класса из models.py. Во время прохождения истории набор полей может отличаться от сегодняшнего определения. Такой подход позволяет операции заполнения работать на нужном этапе. Имена note-<pk> выбраны для однозначности, а не как готовые выразительные адреса редакционного материала.
Разберите две старые записи с одинаковым заголовком. Автоматическое преобразование обоих заголовков в одну строку не удовлетворит уникальности. Добавление первичного ключа решает конфликт учебного заполнения независимо от текста. После этого ограничение уникальности включается для завершённого набора. Если попытаться сделать новое поле обязательным и уникальным сразу, существующие строки не получат корректных разных значений от одного общего значения по умолчанию.
Полные файлы этого шага
Следующие файлы показаны полностью. Остальные согласованные файлы находятся в том же снимке архива; переносите весь каталог при выборе исходного состояния.
notes/models.py
from django.db import models
class Note(models.Model):
title = models.CharField(max_length=160)
slug = models.SlugField(max_length=180, unique=True)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "-pk"]
def __str__(self):
return self.title
notes/migrations/0002_slug.py
from django.db import migrations, models
def fill_slugs(apps, schema_editor):
Note = apps.get_model("notes", "Note")
alias = schema_editor.connection.alias
for note in Note.objects.using(alias).all():
note.slug = f"note-{note.pk}"
note.save(using=alias, update_fields=["slug"])
class Migration(migrations.Migration):
dependencies = [("notes", "0001_initial")]
operations = [
migrations.AddField(model_name="note",
name="slug",
field=models.SlugField(max_length=180,
null=True)),
migrations.RunPython(fill_slugs, migrations.RunPython.noop),
migrations.AlterField(model_name="note",
name="slug",
field=models.SlugField(max_length=180,
unique=True)),
]
notes/urls.py
from django.urls import path
from . import views
app_name = "notes"
urlpatterns = [path("",
views.note_list,
name="list"),
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>
<p>{{ note.body|linebreaksbr }}</p>
<time datetime="{{ note.created_at|date:'c' }}">{{ note.created_at|date:'d.m.Y' }}</time>
</article>
{% endblock %}
Документация и исходники
Механизм этой главы описан в официальной документации Django 5.2. При самостоятельной проверке сначала сопоставьте маршрут, представление и состояние данных, затем сравните результат с указанным ожидаемым поведением. Архив учебного проекта содержит все шестнадцать самостоятельных этапов и договор эксплуатации.