Tutorial: version your own models¶
django CMS pages, aliases and stories are versioned for you the moment you install djangocms-versioning (see Welcome to djangocms-versioning documentation!). This tutorial is about the other case: you have your own model and you want drafts, publishing and history for it too.
We will take a tiny blog app with a single Post model and turn it into a fully
versioned one. By the end you will have created a draft, published it, edited it into
a new draft, and published again — watching the version states change at every step.
It takes about 15 minutes and assumes:
a running django CMS 4 project with djangocms-versioning installed,
a superuser to log in with, and
the blog app below.
Here is our starting point — one model, no versioning:
# blog/models.py
from django.db import models
class Post(models.Model):
author = models.CharField(max_length=80)
title = models.CharField(max_length=120)
body = models.TextField()
def __str__(self):
return self.title
Step 1 — Split the model into a grouper and a content model¶
Versioning needs two models instead of one:
a grouper that represents the post and never changes between versions, and
a content model that holds everything that does change from version to version.
Move the versioned fields (title, body) onto a new PostContent model and
point it back at Post with a foreign key. author stays on Post — the
person who owns the post is the same no matter which version you are looking at:
# blog/models.py
from django.db import models
class Post(models.Model):
"""The grouper: one row per blog post. Holds non-versioned data."""
author = models.CharField(max_length=80)
def __str__(self):
return f"Post by {self.author} ({self.pk})"
class PostContent(models.Model):
"""The content: one row per *version* of a post."""
post = models.ForeignKey(Post, on_delete=models.CASCADE)
title = models.CharField(max_length=120)
body = models.TextField()
def __str__(self):
return self.title
Post is now what gets versioned; each PostContent is one version of it. The
names are up to you — Post/PostContent is just the common convention.
Tip
Decide field-by-field: does this value need its own history? Put it on
PostContent (here, title and body). Should it stay the same across
every version (e.g. the author, an owning site)? Leave it on Post.
Step 2 — Tell versioning about the models¶
Add a cms_config.py to the app. This is where django CMS apps declare how they
plug into the CMS and its ecosystem.
Instead of importing VersionableItem from djangocms-versioning directly, ask
django CMS for it through the versioning contract. get_contract returns the
registered VersionableItem class, so your app depends only on django CMS — not on
a specific versioning package. This is how the ecosystem apps (aliases, stories,
snippets) integrate too:
# blog/cms_config.py
from cms.app_base import CMSAppConfig
from .models import PostContent
class BlogCMSConfig(CMSAppConfig):
djangocms_versioning_enabled = True
def __init__(self, app):
super().__init__(app)
VersionableItem = self.get_contract("djangocms_versioning")
self.versioning = [
VersionableItem(
content_model=PostContent,
grouper_field_name="post",
grouper_admin_mixin="__default__",
),
]
What’s happening here:
djangocms_versioning_enabled = Trueopts the app into versioning.self.get_contract("djangocms_versioning")fetches theVersionableItemclass through the contract, keeping your code decoupled from the versioning implementation. (If you avoid importing directly, your app will also work with altenative implementations honoring the contract).VersionableItemconnects the content model (PostContent) to its grouper via thepostforeign key.grouper_admin_mixin="__default__"adds the versioning columns and actions to thePostadmin automatically (Step 3).
We don’t pass a copy_function: when it is omitted, versioning falls back to its
built-in default_copy, which is all a simple model needs. See
Integrating Versioning for when (and how) to supply a custom one.
Step 3 — Show versions in the admin¶
Register the grouper model. Because we set grouper_admin_mixin="__default__",
the grouper admin gets the author, modified date, state indicator and version actions
added for you.
You also register a small, hidden admin for the content model. You never use it
directly, but versioning’s version-list view needs to reverse the content model’s admin
URLs — without it, opening the version list raises NoReverseMatch. We hide it from
the admin index and redirect it to the grouper admin:
# blog/admin.py
from cms.admin.utils import GrouperModelAdmin
from django.contrib import admin
from django.shortcuts import redirect
from .models import Post, PostContent
@admin.register(Post)
class PostAdmin(GrouperModelAdmin):
# "author" lives on the grouper; versioning adds the version author,
# modified date, state and actions automatically
list_display = ("content__title", "author")
@admin.register(PostContent)
class PostContentAdmin(admin.ModelAdmin):
"""Hidden helper: makes the content model's admin URLs reversible so
versioning's version list works. Users never see or use it."""
def has_module_permission(self, request):
return False # hide from the admin index
def changelist_view(self, request, extra_context=None):
return redirect("admin:blog_post_changelist")
def change_view(self, request, object_id, form_url="", extra_context=None):
return redirect("admin:blog_post_changelist")
GrouperModelAdmin (from django CMS 4.1+) recognises PostContent as the
content model by naming convention, so the grouper admin needs nothing else.
Note
The hidden PostContentAdmin is a temporary workaround. django CMS 5.1’s grouper
admin resolves these URLs itself, so on 5.1 (or a release that backports it) you can
drop the content admin and register only the grouper.
Step 4 — Create the database tables¶
python -m manage makemigrations blog
python -m manage migrate
If your blog already had data in the old single-model table, see the data-migration note in Integrating Versioning — you need to create a grouper for each existing row. For a brand-new app there is nothing extra to do.
Step 5 — Draft, publish, edit, publish¶
Now the fun part. Open a shell and follow along — the comments show what to expect:
python -m manage shell
from django.contrib.auth import get_user_model
from djangocms_versioning.models import Version
from blog.models import Post, PostContent
user = get_user_model().objects.first()
# Create the grouper (with its non-versioned author), then the first
# version of its content.
post = Post.objects.create(author="Ada Lovelace")
content = PostContent.objects.with_user(user).create(
post=post,
title="Hello, versioning",
body="My first versioned post.",
)
# Creating content through the versioned manager also created a Version — as a draft.
version = Version.objects.get_for_content(content)
version.state # 'draft'
Note
Always use .with_user(user).create(...) for versioned content: versioning
needs to know who is creating the version. A plain .create(...) will warn
and skip creating the Version object.
Drafts are private. The default manager only ever returns published content, so the public site cannot see this draft yet:
PostContent.objects.filter(post=post).count() # 0 — nothing is published
Publish it:
version.publish(user)
version.state # 'published'
PostContent.objects.get(post=post).title # 'Hello, versioning'
Now edit it. You never change a published version in place — you copy it into a new draft, change that, and publish when ready:
draft = version.copy(user) # a fresh draft, copied from the published version
draft.state # 'draft'
draft.content.title = "Hello, versioning (revised)"
draft.content.save()
# The public still sees the published version while you work on the draft:
PostContent.objects.get(post=post).title # 'Hello, versioning'
draft.publish(user)
PostContent.objects.get(post=post).title # 'Hello, versioning (revised)'
Publishing the new draft automatically unpublished the previous version, so the post now has two versions — one unpublished, one published:
for v in Version.objects.filter_by_grouper(post).order_by("number"):
print(v.number, v.state)
# 1 unpublished
# 2 published
Step 6 — See it in the admin¶
Log into /admin/, open Posts, and you will see your post with its author,
last-modified date and a state indicator. Use the actions menu to edit (create a
new draft), publish, unpublish or open the manage versions view, which
lists every version with its state and lets you revert to an older one.
What you have learned¶
A versioned model is a grouper plus a content model.
cms_config.pyregisters the pair with aVersionableItem..with_user(user).create(...)makes drafts;version.publish(user)makes them public;version.copy(user)starts the next draft.The default
objectsmanager shows only published content; reach foradmin_managerwhen you need every version (see Working with versioned Pages).
Where to next:
Integrating Versioning — custom copy functions (for posts with related objects like comments or polls), extra grouping fields such as
language, and the contract-basedget_contractregistration that decouples your app from a specific versioning package.Basic concepts — what the four version states mean and why.
Settings for djangocms Versioning and Configure versioning for your workflow — tuning versioning for your project.