Publishing workflow Add a draft/published workflow to documents.

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:

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.

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.

MongoFrames was created by Anthony Blackshaw - a founder of and developer at getme. The project is maintained by the crew at getme along with our contributors. The project's documentation (this site) is managed using ContentTools. The code is licensed under MIT.