2025, Nov 06 12:01

Надежные слаги в Django и Parler: post_save, slugify и unidecode

Разбираем, почему slugify ломает кириллицу в Django с Parler и как сделать стабильные слаги title-pk через post_save и транслитерацию unidecode. Надежно.

Создать аккуратные и предсказуемые слаги в многоязычном проекте Django кажется простым, пока в игру не вступает Parler. Типичный шаблон вроде “title-pk” легко превращается в “-None”, “-9” или вовсе в один числовой хвост, когда заголовки содержат не‑ASCII символы. Ниже — короткий разбор, что именно ломается, как это быстро подтвердить простым дебагом и как исправить проблему надежно, не перекраивая модели.

Настройка и неожиданные симптомы

Модель хранит переведённый title и обычный slug. Наша цель — сформировать slug вида “title-pk”, используя украинский перевод заголовка.

from django.db import models
from django.utils.translation import gettext_lazy as _
from parler.models import TranslatableModel, TranslatedFields
from django.utils.text import slugify
class EventEntry(TranslatableModel):
    translations = TranslatedFields(
        title=models.CharField(max_length=255, verbose_name=_("Event title")),
        body=models.TextField(verbose_name=_("Event body")),
    )
    slug = models.SlugField(blank=True, max_length=255, verbose_name=_("Event slug"))
    def save(self, *args, **kwargs):
        if not self.slug:
            uk_label = self.safe_translation_getter("title", language_code="uk")
            pre_slug = slugify(uk_label)
            self.slug = f"{pre_slug}-{self.pk}"
        super().save(*args, **kwargs)

На первый взгляд всё выглядело корректно, но система упорно выдавала значения вроде “-None” или “-pk”. Интуиция верная: pk недоступен до первоначальной вставки. Казалось логичным поменять порядок и сначала вызвать super().save(), но даже тогда логи показывали пустую основу, когда заголовок был на кириллице.

import logging
class EventEntry(TranslatableModel):
    translations = TranslatedFields(
        title=models.CharField(max_length=255, verbose_name=_("Event title")),
        body=models.TextField(verbose_name=_("Event body")),
    )
    slug = models.SlugField(blank=True, max_length=255, verbose_name=_("Event slug"))
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        if not self.slug:
            uk_label = self.safe_translation_getter("title", language_code="uk")
            pre_slug = slugify(uk_label)
            logging.debug("[DEBUG] Event pre_slug: %s", pre_slug)
            self.slug = f"{pre_slug}-{self.pk}"
            logging.debug("[DEBUG] Event slug: %s", self.slug)
        super().save(*args, **kwargs)
[DEBUG] Event pre_slug:
[DEBUG] Event slug: -9

Что на самом деле происходит

Тут пересекаются два факта. Во‑первых, если генерировать slug внутри save() до первого сохранения, вы получаете pk = None. Во‑вторых, даже если сперва вызвать super().save(), основа из заголовка может схлопнуться в пустую строку, потому что slugify() не транслитерирует не‑ASCII символы. С кириллическим заголовком slugify() вычистит всё слово, оставив лишь хвост -pk. Поэтому в дебаге основа оказалась пустой, а slug — наподобие “-9”. Отдельный трейс вокруг сигнала показал тот же эффект в другой форме.

[DEBUG] base_slug: тест-11
[DEBUG] instance.slug: 11

Сырая основа включала кириллическое слово и id, но после slugify() буквы исчезли, и выжил только номер.

Рабочее решение: post_save и транслитерация

Надёжный подход — дождаться, пока объект будет сохранён и у него появится первичный ключ, затем перед slugify() транслитерировать исходную строку в ASCII. Здесь связка post_save плюс unidecode решает задачу чисто.

import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.text import slugify
from unidecode import unidecode
@receiver(post_save, sender=EventEntry)
def build_slug_post_save(sender, instance, **kwargs):
    if not instance.slug:
        uk_label = instance.safe_translation_getter("title", language_code="uk")
        if uk_label:
            raw = f"{uk_label}-{instance.pk}"
            final_slug = slugify(unidecode(raw))[:255]
            logging.debug("[DEBUG] instance.slug: %s", final_slug)
            instance.slug = final_slug
            instance.save(update_fields=["slug"])

Такая последовательность гарантирует валидный pk и превращает кириллицу в безопасную латиницу перед обработкой slugify(), возвращая ожидаемое “test-11” из “тест-11”.

Почему это важно

В многоязычных API генерация слага находится на стыке моделирования данных и семантики URL. Parler откладывает переведённое содержимое в собственное хранилище; первый вызов save() у вашей модели не гарантирует готовность переведённого поля, а slugify() не спасёт не‑ASCII буквы. Без явной транслитерации и правильного жизненного хука ваши слаги тихо деградируют, ломают ожидания и становятся хрупкими при маршрутизации.

Практические рекомендации

Всегда проверяйте, что у вас реально есть в рантайме. Простейшие логи в стиле print сразу покажут, держите ли вы None, пустую строку или уже очищенное после slugify() значение. Генерируйте slug после того, как строка уже появилась в базе и pk определён, и обязательно транслитерируйте перед slugify(), чтобы сохранить смысл из не‑ASCII заголовков. С парой post_save и unidecode формат “title-pk” остаётся стабильным для разных языков.

Статья основана на вопросе с StackOverflow от kryx и ответе от kryx.