django-content-editor – Editing structured content

Version 1.3-1-gc94d173

https://travis-ci.org/matthiask/django-content-editor.svg?branch=master

Tagline: The component formerly known as FeinCMS’ ItemEditor.

Django’s builtin admin application provides a really good and usable administration interface for creating and updating content. django-content-editor extends Django’s inlines mechanism with an interface and tools for managing and rendering heterogenous collections of content as are often necessary for content management systems. For example, articles may be composed of text blocks with images and videos interspersed throughout.

That, in fact, was one of the core ideas of FeinCMS. Unfortunately, FeinCMS has accumulated much more code than strictly necessary, and I should have done better in this regard. Of course FeinCMS still contains much less code than comparable CMS systems, but we can do even better and make it more obvious what’s going on.

So, django-content-editor.

Note

If you like these ideas you might want to take a look at feincms3.

Example: articles with rich text plugins

First comes a models file which defines a simple article model with support for adding rich text and download content blocks.

app/models.py:

from django.db import models

from content_editor.models import (
    Template, Region, create_plugin_base
)


class Article(models.Model):
    title = models.CharField(max_length=200)
    pub_date = models.DateField(blank=True, null=True)

    # The ContentEditor requires a "regions" attribute or property
    # on the model. Our example hardcodes regions; if you need
    # different regions depending on other factors have a look at
    # feincms3's TemplateMixin.
    regions = [
        Region(key='main', title='main region'),
        Region(key='sidebar', title='sidebar region',
               inherited=False),
    ]

    def __str__(self):
        return self.title


# create_plugin_base does nothing outlandish, it only defines an
# abstract base model with the following attributes:
# - a parent ForeignKey with a related_name that is guaranteed to
#   not clash
# - a region CharField containing the region key defined above
# - an ordering IntegerField for ordering plugin items
# - a get_queryset() classmethod returning a queryset for the
#   Contents class and its helpers (a good place to add
#   select_related and #   prefetch_related calls or anything
#   similar)
# That's all. Really!
ArticlePlugin = create_plugin_base(Article)


class RichText(ArticlePlugin):
    text = models.TextField(blank=True)

    class Meta:
        verbose_name = 'rich text'
        verbose_name_plural = 'rich texts'


class Download(ArticlePlugin):
    file = models.FileField(upload_to='downloads/%Y/%m/')

    class Meta:
        verbose_name = 'download'
        verbose_name_plural = 'downloads'

Next, the admin integration. Plugins are integrated as ContentEditorInline inlines, a subclass of StackedInline that does not do all that much except serve as a marker that those inlines should be treated a bit differently, that is, the content blocks should be added to the content editor where inlines of different types can be edited and ordered.

app/admin.py:

from django import forms
from django.contrib import admin
from django.db import models

from content_editor.admin import (
    ContentEditor, ContentEditorInline
)

from .models import Article, Richtext, Download


class RichTextarea(forms.Textarea):
    def __init__(self, attrs=None):
        # Provide class so that the code in plugin_ckeditor.js knows
        # which text areas should be enhanced with a rich text
        # control:
        default_attrs = {'class': 'richtext'}
        if attrs:
            default_attrs.update(attrs)
        super(RichTextarea, self).__init__(default_attrs)


class RichTextInline(ContentEditorInline):
    model = RichText
    formfield_overrides = {
        models.TextField: {'widget': RichTextarea},
    }
    regions = ['main']  # We only want rich texts in "main" region.

    class Media:
        js = (
            '//cdn.ckeditor.com/4.5.6/standard/ckeditor.js',
            'app/plugin_ckeditor.js',
        )

admin.site.register(
    Article,
    ContentEditor,
    inlines=[
        RichTextInline,
        # The create method serves as a shortcut; for quickly
        # creating inlines:
        ContentEditorInline.create(model=Download),
    ],
)

Here’s an example CKEditor integration. Especially noteworthy are the two signals emitted by the content editor: content-editor:activate and content-editor:deactivate. Since content blocks can be dynamically added and ordered using drag-and-drop, most JavaScript widgets cannot be added only on page load. Also, many widgets do not like being dragged around and break respectively become unresponsive when dropped. Because of this you should listen for those signals.

Note that it is not guaranteed that the former event is only emitted once per inline.

app/static/app/plugin_ckeditor.js:

/* global django, CKEDITOR */
(function($) {

    /* Improve spacing */
    var style = document.createElement('style');
    style.type = 'text/css';
    style.innerHTML = "div[id*='cke_id_'] {margin-left:170px;}";
    $('head').append(style);

    CKEDITOR.config.width = '787';
    CKEDITOR.config.height= '300';
    CKEDITOR.config.format_tags = 'p;h1;h2;h3;h4;pre';
    CKEDITOR.config.toolbar = [[
        'Maximize','-',
        'Format','-',
        'Bold','Italic','Underline','Strike','-',
        'Subscript','Superscript','-',
        'NumberedList','BulletedList','-',
        'Anchor','Link','Unlink','-',
        'Source'
    ]];

    // Activate and deactivate the CKEDITOR because it does not like
    // getting dragged or its underlying ID changed.
    // The 'data-processed' attribute is set for compatibility with
    // django-ckeditor. (Respectively to prevent django-ckeditor's
    // ckeditor-init.js from initializing CKEditor again.)

    $(document).on(
        'content-editor:activate',
        function(event, $row, formsetName) {
            $row.find('textarea[data-type=ckeditortype]').each(function() {
                if (this.getAttribute('data-processed') != '1') {
                    this.setAttribute('data-processed', '1')
                    $($(this).data('external-plugin-resources')).each(function(){
                        CKEDITOR.plugins.addExternal(this[0], this[1], this[2]);
                    });
                    CKEDITOR.replace(this.id, $(this).data('config'));
                }
            });
        }
    ).on(
        'content-editor:deactivate',
        function(event, $row, formsetName) {
            $row.find('textarea[data-type=ckeditortype]').each(function() {
                try {
                    CKEDITOR.instances[this.id] && CKEDITOR.instances[this.id].destroy();
                    this.setAttribute('data-processed', '0')
                } catch(err) {}
            });
        }
    );

    // Or maybe something like this is already sufficient for a
    // simpler scenario:
    //
    // $(document).on(
    //     'content-editor:activate',
    //     function(event, $row, formsetName) {
    //         $row.find('textarea.richtext').each(function() {
    //             CKEDITOR.replace(this.id, CKEDITOR.config);
    //         });
    //     }
    // ).on(
    //     'content-editor:deactivate',
    //     function(event, $row, formsetName) {
    //         $row.find('textarea.richtext').each(function() {
    //             CKEDITOR.instances[this.id] &&
    //             CKEDITOR.instances[this.id].destroy();
    //         });
    //     }
    // );
})(django.jQuery);

Here comes the renderer definition and a really short view.

app/views.py:

from django.utils.html import format_html, mark_safe
from django.views import generic

from content_editor.renderer import PluginRenderer
from content_editor.contents import contents_for_item

from .models import Article, RichText, Download


renderer = PluginRenderer()
renderer.register(
    RichText,
    lambda plugin: mark_safe(plugin.text),
)
renderer.register(
    Download,
    lambda plugin: format_html(
        '<a href="{}">{}</a>',
        plugin.file.url,
        plugin.file.name,
    ),
)


class ArticleView(generic.DetailView):
    model = Article

    def get_context_data(self, **kwargs):
        contents = contents_for_item(self.object, [RichText, Download])
        return super(ArticleView, self).get_context_data(
            content=contents.render_regions(renderer),
            **kwargs
        )

Note

The TemplatePluginRenderer from feincms3 also allows rendering plugins with templates, not only with callables.

After the render_regions call all that’s left to do is add the content to the template.

app/templates/app/article_detail.html:

<article>
    <h1>{{ article }}</h1>
    {{ article.pub_date }}

    {{ content.main }}
</article>
<aside>{{ content.sidebar }}</aside>

Finally, ensure that content_editor and app are added to your INSTALLED_APPS setting, and you’re good to go.

If you also want nice icons to add new items, you might want to use font awesome and the following snippets:

app/admin.py:

from content_editor.admin import ContentEditor, ContentEditorInline
from js_asset import JS

# ... the RichTextInline from above

class ArticleAdmin(ContentEditor):
    inlines = [
        RichTextInline,
        ContentEditorInline.create(model=Download),
    ]

    class Media:
        js = (
            JS('https://use.fontawesome.com/releases/v5.0.10/js/all.js', {
                'defer': 'defer',
                'integrity': 'sha384-slN8GvtUJGnv6ca26v8EzVaR9DC58QEwsIk9q1QXdCU8Yu8ck/tL/5szYlBbqmS+',  # noqa
                'crossorigin': 'anonymous',
            }, static=False),
            'app/plugin_buttons.js',
        )

app/plugin_buttons.js:

(function($) {
    $(document).on('content-editor:ready', function() {
        ContentEditor.addPluginButton(
            'app_richtext',
            '<i class="fas fa-pencil-alt"></i>'
        );
        ContentEditor.addPluginButton(
            'app_download',
            '<i class="fas fa-download"></i>'
        );
    });
})(django.jQuery);

Parts

Regions

The included Contents class and its helpers (contents_*) and the ContentEditor admin class expect a regions attribute or property (not a method) on their model (the Article model above) which returns a list of Region instances.

Regions have the following attributes:

  • title: Something nice, will be visible in the content editor.
  • key: The region key, used in the content proxy as attribute name for the list of plugins. Must contain a valid Python identifier.
  • inherited: Only has an effect if you are using the inherit_from argument to contents_for_item (or contents_for_mptt_item which passes the node’s ancestors automatically): Model instances inherit content from their other instances if a region with inherited = True is empty.

You are free to define additional attributes – simply pass them when instantiating a new region.

Templates

Various classes will expect the main model to have a template attribute or property which returns a Template instance. django-content-editor does not use the template class itself, but feincms3 includes a mixin which allows content managers to choose from a selection of templates per model instance.

Templates have the following attributes:

  • title: Something nice.
  • key: The template key. Must contain a valid Python identifier.
  • template_name: A template path.
  • regions: A list of region instances.

As with the regions above, you are free to define additional attributes.

Contents class and helpers

The content_editor.contents module offers a few helpers for fetching content blocks from the database. The Contents class knows how to group content blocks by region and how to merge contents from several main models. This is especially useful in inheritance scenarious, for example when a page in a hierarchical page tree inherits some aside-content from its ancestors.

Note

Historical note

The Contents class and the helpers replace the monolithic ContentProxy concept in FeinCMS.

Simple usage is as follows:

from content_editor.contents import Contents

article = Article.objects.get(...)
c = Contents(article.regions)
for item in article.app_richtext_set.all():
    c.add(item)
for item in article.app_download_set.all():
    c.add(item)

# Returns a list of all items, sorted by the definition
# order of article.regions and by item ordering
list(c)

# Returns a list of all items from the given region
c['main']
# or
c.main

# How many items do I have?
len(c)

# Inherit content from the given contents instance if one of my
# own regions is empty and has its "inherited" flag set.
c.inherit_regions(some_other_contents_instance)

# Plugins from unknown regions end up in _unknown_region_contents:
c._unknown_region_contents

For most use cases you’ll probably want to take a closer look at the following helper methods instead of instantiating a Contents class directly:

contents_for_items

Returns a contents instance for a list of main models:

articles = Article.objects.all()[:10]
contents = contents_for_items(
    articles,
    plugins=[RichText, Download],
)

something = [
    (article, contents[article])
    for article in articles
]

contents_for_item

Returns the contents instance for a given main model (note that this helper calls contents_for_items to do the real work):

# ...
contents = contents_for_item(
    article,
    plugins=[RichText, Download],
)

It is also possible to add additional items for inheriting regions. This is most useful with a page tree where i.e. sidebar contents are inherited from ancestors (this example uses methods added by django-tree-queries as used in feincms3):

page = ...
contents = contents_for_item(
    page,
    plugins=[,,,],
    page.ancestors().reverse(),  # Prefer content closer to the
                                 # current page
)

contents_for_mptt_item

Returns the contents instance for a given main model, inheriting content from ancestors if a given region is inheritable and empty in the passed item:

page = Page.objects.get(path=...)
contents = contents_for_mptt_item(
    page,
    plugins=[RichText, Download])

PluginRenderer class

Note

Most real-world use cases are better served by using feincms3’s TemplatePluginRenderer.

Example:

renderer = PluginRenderer()
# Register renderers -- also handles subclasses
# Fallback for unknown plugins is a HTML comment containing the
# model label (app.model) and plugin.__str__
# The return value of renderers is autoescaped.
renderer.register(
    RichText,
    lambda plugin: mark_safe(plugin.text))
renderer.register(
    Image,
    lambda plugin: format_html(
        '<img src={}" alt="">',
        plugin.image.url,
    ))

article = ...
contents = contents_for_item(
    article,
    plugins=[RichText, Image])

return render(request, 'app/article_detail.html', {
    'object': article,
    'content': {
        region.key: renderer.render(contents[region.key])
        for region in article.regions
    },
})

Design decisions

About rich text editors

We have been struggling with rich text editors for a long time. To be honest, I do not think it was a good idea to add that many features to the rich text editor. Resizing images uploaded into a rich text editor is a real pain, and what if you’d like to reuse these images or display them using a lightbox script or something similar? You have to resort to writing loads of JavaScript code which will only work on one browser. You cannot really filter the HTML code generated by the user to kick out ugly HTML code generated by copy-pasting from word. The user will upload 10mb JPEGs and resize them to 50x50 pixels in the rich text editor.

All of this convinced me that offering the user a rich text editor with too much capabilities is a really bad idea. The rich text editor in FeinCMS only has bold, italic, bullets, link and headlines activated (and the HTML code button, because that’s sort of inevitable – sometimes the rich text editor messes up and you cannot fix it other than going directly into the HTML code. Plus, if someone really knows what they are doing, I’d still like to give them the power to shot their own foot).

If this does not seem convincing you can always add your own rich text plugin with a different configuration (or just override the rich text editor initialization template in your own project). We do not want to force our world view on you, it’s just that we think that in this case, more choice has the bigger potential to hurt than to help.

Plugins

Images and other media files are inserted via objects; the user can only select a file and a display mode (f.e. float/block for images or something…). An article’s content could look like this:

  • Rich Text
  • Floated image
  • Rich Text
  • YouTube Video Link, embedding code is automatically generated from the link
  • Rich Text

It’s of course easier for the user to start with only a single rich text field, but I think that the user already has too much confusing possibilities with an enhanced rich text editor. Once the user grasps the concept of content blocks which can be freely added, removed and reordered using drag/drop, I’d say it’s much easier to administer the content of a webpage. Plus, the content blocks can have their own displaying and updating logic; implementing dynamic content inside the CMS is not hard anymore, on the contrary. Since content blocks are Django models, you can do anything you want inside them.

Glossary

  • Main model: (Bad wording – not happy with that). The model to which plugins may be added. This model uses the content editor admin class.
  • Plugin: A content element type such as rich text, download, and image or whatever.
  • Content block: A content element instance belonging to a main model instance. Also called item sometimes in the documentation above.

Change log

Next version

  • Added configuration to make running prettier and ESLint easy.

1.3 (2018-12-10)

  • Added back the possibility to move new content blocks in-between other content blocks without having to save first. To achieve this the CSS and JavaScript of the content editor was rewritten using flex ordering instead of modifying the order of elements in the DOM. This also implies that reordering content blocks does not require deactivation and activation steps anymore e.g. to preserve the functionality of a rich text editor, possibly making it easier to implement custom editors for individual plugins.
  • Added a button to the content editor to toggle the content of inlines (making reordering contents easier).
  • Added a workaround for a bug with Django’s responsive administration panel CSS where form fields where shown below the 767px breakpoint despite them being .hidden.
  • Reformatted the CSS and JavaScript code using prettier.

1.2 (2018-10-06)

  • Fixed our use of internal API of forms.Media that will be removed in Django 2.0.
  • Fixed an elusive bug with our formsets handling. Newly added content blocks have to be saved before they can be reordered.
  • Fixed a handful of minor CSS bugs.
  • Updated the documentation with a few improved recommendations.
  • Moved plugin buttons before the dropdown.
  • Removed the JavaScript’s dependency on the exact related_name value of plugins.

1.1 (2017-06-27)

  • Moved the JS form assets helper to django-js-asset, thereby raising our own Python code coverage to 100%.
  • Added Django 1.11 and Django@master to the Travis build.
  • Added a tox configuration file for building docs and running style checks and the test suite.
  • Added a check which errors if the model registered with the ContentEditor has no regions attribute or property.
  • Expanded the documentation a bit.
  • Fixed occasional problems when sorting by keeping the empty inline formsets at the end at all times. Thanks to Tom Van Damme for the contribution!

1.0 (2017-01-23)

  • Moved the regions inheritance handling from contents_for_mptt_item to contents_for_item to make it reusable outside MPTT hierarchies.
  • Reworded the introduction to the documentation.

0.10 (2016-09-06)

  • Changed Region and Template to extend types.SimpleNamespace on Python versions that support this (>3.3)
  • Allowed restricting individual plugin types to a subset of available regions by setting ContentEditorInline.plugins to a list of region keys. Thanks to Tom Van Damme for the contribution!
  • Removed Django from install_requires – updating django-content-editor does not necessarily mean users want to update Django as well.

0.9 (2016-08-12)

  • Some browsers do not support Math.sign
  • Automatically open the first tab with errors when using tabbed fieldsets.
  • Improve visibility of plugin fieldsets.
  • Fixed widgets using their own size in tabbed fieldsets and the content editor (for example django-versatileimagefield’s primary point of interest field).
  • Use django.contrib.staticfiles’ static URL generation if it is installed.

0.8 (2016-07-07)

  • Modified PluginRenderer.render and PluginRenderer.render_content to pass on keyword arguments (if any) to the registered render functions.
  • Made tabbed fieldsets’ titles stand out if the tab contains invalid fields.

0.7 (2016-06-29)

  • Raise tests coverage back to 100% after the PluginRenderer.render change in 0.6.
  • Simplify the implementation of the return value of PluginRenderer.render. Empty regions are now falsy again.

0.6 (2016-06-25)

  • The return value of PluginRenderer.render now allows outputting individual items as well as the concatenated output of all items as before.
  • Added this change log.

0.5 (2016-06-21)

  • Made tests using Django@master pass again by switching to my fork of django-mptt.
  • Simplified the way package data is specified in setup.py.

0.4 (2016-04-14)

  • Added a check to Django’s checks framework for custom content editor fieldsets.
  • Streamlined the implementation of PluginRenderer, allow rendering a single plugin.
  • Added documentation for Contents and its helpers.
  • Added infrastructure for running the tests using ./setup.py test.

0.3 (2016-02-28)

  • Replaced the ContentEditorForm with a formfield_for_dbfield override for easier model form customization.
  • Replaced the ContentProxy concept with a generic Contents class and various helpers for fetching contents.
  • Added a simple PluginRenderer for registering render functions for a plugin class tree.

0.2 (2016-02-26)

  • Added comments, documentation.
  • Fixed the JavaScript tag generation by the JS class.
  • Only auto-fill our own ordering fields.

0.1 (2016-02-16)

Initial public release of django-content-editor.