The recording for this lesson has not been published yet.
Look at what Post, Category, Tag and
Comment now have in common. Each one wants to know when it was created and when
it was last touched, and the ones that appear in a URL also want a slug. Copy those fields
into four models and you have four places to keep in sync.
# the copy-paste you want to stop writing
class Post(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
slug = models.SlugField(max_length=200, unique=True)
...
class Category(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
slug = models.SlugField(max_length=60, unique=True)
...
Python already has the answer - inheritance. Django gives it a database-aware twist, and the important part is choosing the right kind.
A slug is the human-readable part of a URL: /post/how-django-orm-works/ instead
of /post/17/. SlugField is a CharField with a
validator that allows only letters, numbers, hyphens and underscores, and it is indexed by
default (db_index=True).
from django.utils.text import slugify
slugify("How the Django ORM works") # 'how-the-django-orm-works'
slugify("Django 5.0: what's new?") # 'django-50-whats-new'
slugify("Grusse aus Koln") # 'grusse-aus-koln'
By default non-ASCII letters are transliterated or dropped, which turns a Cyrillic or Greek
title into an empty slug. Pass slugify(title, allow_unicode=True) and set
SlugField(allow_unicode=True) if your blog is not written in English.
Generating it once, in save(), is the pattern to memorise. Note the two
deliberate choices: only fill it when it is empty, so an existing URL never changes silently
and breaks your links, and add a suffix until it is unique.
# common/models.py
from django.db import models
from django.utils.text import slugify
class SluggedModel(models.Model):
slug = models.SlugField(max_length=200, unique=True, blank=True)
slug_source_field = "title"
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self._unique_slug()
super().save(*args, **kwargs)
def _unique_slug(self):
base = slugify(getattr(self, self.slug_source_field))[:190] or "item"
slug = base
suffix = 2
model = self.__class__
while model.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{suffix}"
suffix += 1
return slug
def get_absolute_url(self):
raise NotImplementedError
bulk_create(), update() and data migrations do not call
save(). If uniqueness really matters, keep unique=True on the
column so the database enforces it - and be ready to catch
IntegrityError under concurrency, because the check-then-insert above has a
race window.
Two slugs can share a title across models, so a global unique slug is often wrong. Scope it instead:
class Post(models.Model):
slug = models.SlugField(max_length=200)
published_date = models.DateTimeField(blank=True, null=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["category", "slug"], name="unique_slug_per_category"
),
]
# common/models.py
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta:
abstract = True
get_latest_by = "created_at"
| Option | Set when | Editable in forms | Respects your value |
|---|---|---|---|
auto_now_add=True | on insert only | no | no |
auto_now=True | on every save | no | no |
default=timezone.now | on insert, if unset | yes | yes |
auto_now is silently skipped by queryset.update(), and with
save(update_fields=[...]) you must include the field yourself:
post.save(update_fields=["title", "updated_at"]). When you need to control the
value - imports, fixtures, tests - use default=timezone.now instead.
This is the decision that matters. One keyword in Meta changes what happens in
the database.
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True # <- remove this line and everything changes
| Abstract base class | Multi-table inheritance | |
|---|---|---|
| Tables | one per child, fields copied | parent table plus one per child |
| Reads | no join | an implicit join on every access |
| Query the base | impossible - it is not a model | Content.objects.all() works |
| Point a FK at it | no | yes |
| Use when | you are sharing fields and behaviour | you genuinely need a common table |
In practice you want abstract almost always. Multi-table inheritance is a hidden join on
every query, and the case it solves - "give me all content, whatever kind" - is usually
better served by a plain foreign key or a type field.
There is also proxy inheritance, which adds no fields and no table, only
different Python behaviour - a different default manager or Meta.ordering:
class PublishedPost(Post):
objects = PublishedManager()
class Meta:
proxy = True
ordering = ["-published_date"]
Keep them in the common app from lesson 1.1, each doing exactly one thing:
# common/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta:
abstract = True
class SoftDeleteQuerySet(models.QuerySet):
def alive(self):
return self.filter(deleted_at__isnull=True)
def dead(self):
return self.filter(deleted_at__isnull=False)
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
objects = SoftDeleteQuerySet.as_manager()
class Meta:
abstract = True
def delete(self, *args, **kwargs):
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def restore(self):
self.deleted_at = None
self.save(update_fields=["deleted_at"])
class AuthoredModel(models.Model):
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s_set",
)
class Meta:
abstract = True
Two children inheriting the same literal related_name="posts" would clash.
Use "%(app_label)s_%(class)s_set" and Django fills in the child's app and
class name - blog_post_set, blog_comment_set.
# blog/models.py
from common.models import SluggedModel, TimeStampedModel
from .managers import PostQuerySet
class Post(TimeStampedModel, SluggedModel):
title = models.CharField(max_length=200)
text = models.TextField()
published_date = models.DateTimeField(blank=True, null=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts"
)
category = models.ForeignKey(
Category, on_delete=models.PROTECT, related_name="posts", null=True, blank=True
)
tags = models.ManyToManyField(Tag, related_name="posts", blank=True)
objects = PostQuerySet.as_manager()
slug_source_field = "title"
class Meta:
ordering = ["-published_date"]
indexes = [models.Index(fields=["-published_date"], name="post_published_idx")]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("blog:post_detail", kwargs={"slug": self.slug})
Two details that trip people up. An abstract parent's Meta is inherited, so if
the child defines its own Meta and you want to keep the parent's options,
inherit explicitly:
class Post(TimeStampedModel):
class Meta(TimeStampedModel.Meta):
abstract = False # Django resets this for you, but be explicit
ordering = ["-published_date"]
And changing the base class does generate migrations in every child app, because each child table gets the columns:
python manage.py makemigrations
Migrations for 'blog':
blog/migrations/0008_post_created_at_post_updated_at.py
+ Add field created_at to post
+ Add field updated_at to post
Migrations for 'comments':
comments/migrations/0003_comment_created_at_comment_updated_at.py
+ Add field created_at to comment
Since auto_now_add cannot invent a value for existing rows, Django will ask for
a one-off default - or you use the three-step pattern from lesson 2.2.
common/models.py with TimeStampedModel and
SluggedModel as abstract base classes.Post, Category, Tag and
Comment inherit from them, generate the migrations, and inspect one with
sqlmigrate to see that no extra table was created.post_detail to look up by slug instead of
pk, add get_absolute_url() and use it in your templates.-2.abstract = True, run
makemigrations --dry-run --verbosity 3 and describe what Django wants to
create. Then put it back.SoftDeleteModel to Comment and make the detail template
show only comments that are alive.