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 = True opts the app into versioning.

  • self.get_contract("djangocms_versioning") fetches the VersionableItem class 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).

  • VersionableItem connects the content model (PostContent) to its grouper via the post foreign key.

  • grouper_admin_mixin="__default__" adds the versioning columns and actions to the Post admin 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.py registers the pair with a VersionableItem.

  • .with_user(user).create(...) makes drafts; version.publish(user) makes them public; version.copy(user) starts the next draft.

  • The default objects manager shows only published content; reach for admin_manager when you need every version (see Working with versioned Pages).

Where to next: