A common requirement for CMS projects is to implement a publishing workflow where content is created and modified in draft before being published. This snippet presents an approach for creating such a workflow and is based on a modified version of the workflow we currently use for CMS projects.

This article and the code snippets within it reference Flask throughout, it's possible to apply this snippet to other web frameworks or a non-web application but as it was written for Flask originally the code examples in particular are skewed towards that framework.

The publishing workflow

The workflow presented in this snippet stores draft and published documents in two separate database collections while providing a single Frame-like class to manage both versions of the document.

As an example let's imagine we want to add a news section to our site. Our news section will feature articles which will initially be created in draft before being published to the live site.

  • When a new article is created it is initially stored in a collection named Article_draft. A unique ID (_uid) is generated for the article which will be used to bind it to its published counterpart along with a revision number (revision) which will be used to determine if there are draft changes waiting to be published.
  • Any updates to the article are applied to the draft document in the Article_draft collection.
  • When the article is ready we publish it. Publishing a document creates or updates a counterpart document within the Article collection using details from the draft document. The article will at this point be live on the site.
  • If we make further changes to the article those changes will be applied to the draft document and a new revision number will be stored.
  • If we published the article subsequently, details from the draft document will be copied to the published counterpart document (including the revision number).

The PublisherFrame class

The PublisherFrame collection/class is responsible for handling the publishing workflow and presenting a single interface despite managing documents across two collections.

# publishing.py

from contextlib import contextmanager
from datetime import datetime
from uuid import uuid4

from flask import g
from mongoframes import *
import pymongo

__all__ = ['PublisherFrame']


class PublisherFrame(Frame):
    """
    The PublisherFrame class supports documents that implement the draft >
    published workflow.
    """

    _fields = {'_uid', 'revision'}
    _unpublished_fields = {'_id'}

    def __init__(self, *args, **kwargs):
        super(PublisherFrame, self).__init__(*args, **kwargs)

        # Ensure a UID is assigned to the document
        if not self._uid:
            self._uid = str(uuid4())

    @property
    def can_publish(self):
        """
        Return True if there is a draft version of the document that's ready to
        be published.
        """
        with self.published_context():
            published = self.one(
                Q._uid == self._uid,
                projection={'revision': True}
                )

        if not published:
            return True

        with self.draft_context():
            draft = self.one(Q._uid == self._uid, projection={'revision': True})

        return draft.revision > published.revision

    @property
    def can_revert(self):
        """
        Return True if we can revert the draft version of the document to the
        currently published version.
        """

        if self.can_publish:
            with self.published_context():
                return self.count(Q._uid == self._uid) > 0

        return False

    def get_publisher_doc(self):
        """Return a publish safe version of the frame's document"""
        with self.draft_context():
            # Select the draft document from the database
            draft = self.one(Q._uid == self._uid)
            publisher_doc = draft._document

            # Remove any keys from the document that should not be transferred
            # when publishing.
            self._remove_keys(publisher_doc, self._unpublished_fields)

        return publisher_doc

    def publish(self):
        """
        Publish the current document.

        NOTE: You must have saved any changes to the draft version of the
        document before publishing, unsaved changes won't be published.
        """
        publisher_doc = self.get_publisher_doc()

        with self.published_context():
            # Select the published document
            published = self.one(Q._uid == self._uid)

            # If there's no published version of the document create one
            if not published:
                published = self.__class__()

            # Update the document
            for field, value in publisher_doc.items():
                setattr(published, field, value)

            # Save published version
            published.upsert()

        # Set the revisions number for draft/published version, we use PyMongo
        # directly as it's more convenient to use the shared `_uid`.
        now = datetime.now()

        with self.draft_context():
            self.get_collection().update(
                {'_uid': self._uid},
                {'$set': {'revision': now}}
                )

        with self.published_context():
            self.get_collection().update(
                {'_uid': self._uid},
                {'$set': {'revision': now}}
                )

    def new_revision(self, *fields):
        """Save a new revision of the document"""

        # Ensure this document is a draft
        if not self._id:
            assert g.get('draft'), \
                    'Only draft documents can be assigned new revisions'
        else:
            with self.draft_context():
                assert self.count(Q._id == self._id) == 1, \
                        'Only draft documents can be assigned new revisions'

        # Set the revision
        if len(fields) > 0:
           fields.append('revision')

        self.revision = datetime.now()

        # Update the document
        self.upsert(*fields)

    def delete(self):
        """Delete this document and any counterpart document"""

        with self.draft_context():
            draft = self.one(Q._uid == self._uid)
            if draft:
                super(PublisherFrame, draft).delete()

        with self.published_context():
            published = self.one(Q._uid == self._uid)
            if published:
                super(PublisherFrame, published).delete()

    def revert(self):
        """Revert the document to currently published version"""

        with self.draft_context():
            draft = self.one(Q._uid == self._uid)

        with self.published_context():
            published = self.one(Q._uid == self._uid)

        for field, value in draft._document.items():
            if field in self._unpublished_fields:
                continue
            setattr(draft, field, getattr(published, field))

        # Revert the revision
        draft.revision = published.revision

        draft.update()

    @classmethod
    def get_collection(cls):
        """Return a reference to the database collection for the class"""

        # By default the collection returned will be the published collection,
        # however if the `draft` flag has been set against the global context
        # (e.g `g`) then the collection returned will contain draft documents.

        if g.get('draft'):
            return getattr(
                cls.get_db(),
                '{collection}_draft'.format(collection=cls._collection)
                )

        return getattr(cls.get_db(), cls._collection)

    # Contexts

    @classmethod
    @contextmanager
    def draft_context(cls):
        """Set the context to draft"""
        previous_state = g.get('draft')
        try:
            g.draft = True
            yield
        finally:
            g.draft = previous_state

    @classmethod
    @contextmanager
    def published_context(cls):
        """Set the context to published"""
        previous_state = g.get('draft')
        try:
            g.draft = False
            yield
        finally:
            g.draft = previous_state

What is g?

In this instance g is a global context provided by Flask, it allows us to share variables globally per request.

Depending on the web framework you're using how you share information per request will vary, but the requirement is same, we need to be able to set a flag globally per request to determine if we're selecting from the draft or published collections.

I have to admit this approach is not as easy to implement in Tornado and Django, it is possible using context stacks but a simpler option is to set the flag against the request object and pass the request object to the draft_context and published_context methods and any methods that call them.

Working in draft and published contexts

To show how draft and published contexts work we're going to model an article with draft and published states as well as define views to update, publish and view articles. First we define the Article class:

# articles.py

from publishing import PublisherFrame

__all__ = ['Article']


class Article(PublisherFrame):
    """
    A class for managing articles published on our site.
    """

    _fields = PublisherFrame._fields | {
        'title',
        'slug',
        'body'
        }

Next we define our web views for updating, publishing and viewing the article.

We assume that the application (app) and form for validating article updates (UpdateArticleForm) exist but we don't show the code for them as it's not important for the snippet. If you're not sure how to create a Flask app there's a snippet for that: Using with Flask.

from flask import abort, flash, redirect, render_template, request
from mongoframes import *

from articles import Article
from forms.articles import UpdateArticleForm


@app.route('/update-article/<uid>', methods=['GET', 'POST'])
def update_article(uid):
    """Update an article"""

    # Switch to draft
    with Article.draft_context():

        # Find the article
        article = Article.one(Q._uid == uid)
        if not article:
            abort(404)

        # Validate the form on POST
        form = UpdateArticleForm(article)
        if form.validate_on_submit():

            # Apply the changes
            article.title = form.data['title']
            article.body = form.data['body']
            article.new_revision()

            flash('Changes saved.')
            return redirect(url_for('update_article', uid=article._uid))

        return render_template('update_article.html', form=form, article=article)

@app.route('/publish-article/<uid>', methods=['POST'])
def publish_article(uid):
    """Publish an article"""

    # Find the article
    article = Article.one(Q._uid == uid)
    if not article:
        abort(404)

    # Publish the article
    if article.can_publish:
        article.publish()

    flash('Published')
    return redirect(url_for('update_article', uid=article._uid))

@app.route('/articles/<path:slug>')
def view_article(slug):
    """View an article"""

    # If the user flags that they want to see a draft of the article then we
    # select the article in draft.
    if request.args.get('draft'):
        with Article.draft_context():
            article = Article.one(Q.slug == slug)
    else:
        with Article.published_context():
            article = Article.one(Q.slug == slug)

    render_template('article.html', article=article)

In our application when making changes to an article we set the context to draft so that our changes apply to the draft document, when publishing an article the context doesn't need to be set as the publish method takes care of this for us, and finally when we view the article we set the context based on whether the user has set the draft request argument or not.

By working within the draft and published contexts switching between the relevant collection for the article is done for us.

Whilst we use the published_context explicitly within the view_article function, we typically make the published_context the default and therefore only use draft_context explicitly.

The code within this article is available in the MongoFrames repository within the snippets directory.