Using a slug in Django
Generating slugs automatically in Django without packages - Two easy and solid approaches
Slug generation management
One important task when developing a Django website is to have pretty
A site's URL structure should be as simple as possible. Consider organizing your content so that URLs are constructed logically and in a manner that is most intelligible to humans (when possible, readable words rather than long ID numbers)
Options
We will explore two approaches to have clean URLs, from having the
typical Django’s URLs with the object/pk scheme, like
/article/12345
to have one of these:
-
pk and slugs: object/pk-slug like
/article/12345-my-example-title
where we add the object’s slug after the primary key. -
unique slug: generate a unique slugs without showing the primary key like
/article/my-example-title
A slug is a short label for something, containing only letters, numbers, underscores or hyphens. They’re generally used in URLs..
Example app
The following app consisting of an Article
model with a
title
will help to show how to use the pk-slug URL.
Example blog/views.py
:
from django.views.generic.detail import DetailView
from blog.models import Article
class ArticleDetailView(DetailView):
model = Article
Example blog/models.py
:
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=100)
Example djangoslugs/urls.py
:
from django.urls import path
from blog.views import ArticleDetailView
urlpatterns = [
path('blog/<int:pk>/', ArticleDetailView.as_view(), name='article-detail'),
]
Demo and Repo
A working demo is available at https://django-slugs-example-app.herokuapp.com/ that shows how both approaches work.
And its repo at: https://github.com/marcanuy/django-slugs-example-app using the above scheme.
First approach: PK and Slug
Pros
This approach increases Web Application security by avoiding an authorization attack: Insecure Direct Object References (IDOR)
Insecure Direct Object References occur when an application provides direct access to objects based on user-supplied input. As a result of this vulnerability attackers can bypass authorization and access resources in the system directly, for example database records or files.
This can be prevented by using both, an object primary key and the slug, as the Simple Object Mixin query_pk_and_slug docs states:
When applications allow access to individual objects by a sequential primary key, an attacker could brute-force guess all URLs; thereby obtaining a list of all objects in the application.
If users with access to individual objects should be prevented from obtaining this list, setting query_pk_and_slug to True will help prevent the guessing of URLs as each URL will require two correct, non-sequential arguments. Simply using a unique slug may serve the same purpose, but this scheme allows you to have non-unique slugs.
Note
Many apps dedicated to manage slugs comes with a max_length
restriction for its size and they truncate the string according to
that value.
We are gonna use the same length as the slugified field.
1. Use SlugField
There is a special model Field type in Django for slugs: SlugField.
Create a field named slug
with type: SlugField
.
in blog/models.py
:
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
class ArticlePkAndSlug(models.Model):
title = models.CharField(
max_length=settings.BLOG_TITLE_MAX_LENGTH
)
slug = models.SlugField(
default='',
editable=False,
max_length=settings.BLOG_TITLE_MAX_LENGTH,
)
def get_absolute_url(self):
kwargs = {
'pk': self.id,
'slug': self.slug
}
return reverse('article-pk-slug-detail', kwargs=kwargs)
def save(self, *args, **kwargs):
value = self.title
self.slug = slugify(value, allow_unicode=True)
super().save(*args, **kwargs)
URLs in djangoslugs/urls.py
:
...
path('blog/<int:pk>-<str:slug>/', ArticleDetailView.as_view() , name='article-detail')
And in the view: blog/views.py
class Article(DetailView):
model = Article
query_pk_and_slug = True
Before saving the instance, we convert the title to a slug with the slugify Django command, that basically replaces spaces by hyphens.
As SlugField inherits from CharField, it comes with the attribute
max_length
which handles the maximum length of the string it
contains at database level.
If we use a SlugField without specifying its max_length
attribute,
it gets the value of 50
by default, which can lead to problems when
we generate the string from a bigger max_length
field.
So the trick is to make them both use the same length.
Second approach: unique slugs
In this case title and slug don’t need to have the same
max_length
, but this brings two issues when generating the slug:
- Truncate the slug to respect
max_length
- Control the slug uniqueness by adding a suffix if another slug with the same string exists1
- Avoid generating a new slug if it already has one when saving an existing instance
1. Truncate the slug
Before generating the slug, get the max_length value max_length = self._meta.get_field('slug').max_length
and truncate at that position
slug = slugify(self.title)[:max_length]
.
2. Ensure uniqueness
For each slug candidate we make sure it is unique by testing against the database until we have a non existing one.
import itertools
...
slug_candidate = slug_original = slugify(self.title)
for i in itertools.count(1):
if not Article.objects.filter(slug=slug_candidate).exists():
break
slug_candidate = '{}-{}'.format(slug_original, i)
3. Avoid regenerating slug
To avoid generating the slug each time you save an existing model,
detect if it is an update of the model on each call of model’s
save()
method.
def _generate_slug(self):
...
def save(self, *args, **kwargs):
if not self.pk:
self._generate_slug()
super().save(*args, **kwargs)
All together
In save
method:
import itertools
...
class ArticleUniqueSlug(Article):
def _generate_slug(self):
max_length = self._meta.get_field('slug').max_length
value = self.title
slug_candidate = slug_original = slugify(value, allow_unicode=True)
for i in itertools.count(1):
if not ArticleUniqueSlug.objects.filter(slug=slug_candidate).exists():
break
slug_candidate = '{}-{}'.format(slug_original, i)
self.slug = slug_candidate
def save(self, *args, **kwargs):
if not self.pk:
self._generate_slug()
super().save(*args, **kwargs)
In view blog/views.py
:
class Article(DetailView):
model = ArticlePkAndSlug
query_pk_and_slug = False
Looking like:
]
References
- https://www.owasp.org/index.php/OWASP_Testing_Guide_v4_Table_of_Contents
- Keep a simple URL structure https://support.google.com/webmasters/answer/76329?hl=en
- Slugify should take an optional max length https://code.djangoproject.com/ticket/15307
-
Slug Uniqueness Code based in https://keyerror.com/blog/automatically-generating-unique-slugs-in-django . ↩︎
Comments
Post a Comment