Architecture

Core Design: Grouper + Content Pattern

This framework adopts django CMS’s own content object pattern. In short, an editable item is split into a grouper (stable identity) and one or more content rows (the editable, per-language, versioned state); foreign keys point at the grouper so they survive translations and version copies. For the full rationale, see the upstream explanation, Content objects.

djangocms-custom-content uses a two-model pattern:

Grouper - One per item (e.g., Article)
  • Persistent identity

  • Minimal data

  • Links to many Content objects

Content - One per language (e.g., ArticleContent)
  • Language-specific data (title, body)

  • Version history automatically supported

  • Quick language switching

Why This Design?

  • Multilingual by default - Each language is a Content row pointing at the same grouper.

  • Version history - Versioning copies the Content row (new primary key); the grouper is untouched.

  • Stable references - Because foreign keys and RelationField relations target the grouper, not a Content row, they survive translations and version copies.

  • Cheap reads - get_content() issues a single query and caches every language of the grouper, so subsequent language lookups hit the cache rather than the database.

Example:

# One Article (grouper)
article = Article.objects.create(name="My Article")

# Multiple ArticleContent (per language)
content_en = ArticleContent.objects.create(
    article=article,
    language="en",
    title="English Title",
    body="English content..."
)

content_de = ArticleContent.objects.create(
    article=article,
    language="de",
    title="German Title",
    body="German content..."
)

Reading content: get_content vs get_admin_content

A grouper exposes two accessors, and the difference matters when versioning is enabled:

  • get_content() - the published content for a language (defaults to the current language). This is what you use in public templates.

  • get_admin_content() - the latest content, including drafts, optimised for admin listings.

article.get_content(language="en")        # published — for the site
article.get_admin_content(language="en")  # latest draft — for the admin

Because relation accessors return groupers (e.g. post.authors.all() yields Person groupers), reach their displayable fields through one of these accessors: person.get_admin_content().name.

Rendering: template defaults

Content models render without any wiring, thanks to a naming convention. By default a content object looks for:

<app_label>/<modelname>_detail.html

So ArticleContent in an app called my_content renders from my_content/articlecontent_detail.html, and the object is available in that template under its model name (articlecontent). The _detail part is template_name_suffix, and the same default serves both the app-hook detail view and the frontend editor — one template covers both.

Need a different template? Override get_template() (the bundled blog returns "blog/detail.html"):

class ArticleContent(AbstractCustomContent):
    def get_template(self):
        return "my_content/article.html"

The Expose content at a URL (app hooks) guide spells out the full contract — which context variables each render path provides, and how an override interacts with the app-hook view.

Not everything needs a grouper

A content model becomes part of a grouper/content pair only when it declares a foreign key to an AbstractCustomGrouper. Skip that foreign key and you get a simpler, perfectly valid shape — like the bundled FlatCategory — that opts out of versioning, app hooks and the grouper admin, yet can still be used as a relation target. Start simple; add a grouper only when you actually need versions or languages.

Relations Are Grouper-Anchored

Relations between content are declared with RelationField on a grouper, and read like a ManyToManyField:

from djangocms_custom_content.relations import RelationField

class BlogPost(AbstractCustomGrouper):
    authors = RelationField("people.Person", related_name="authored_posts", ordered=True)

Each relation gets its own through table with a concrete source foreign key to the owning grouper and a GenericForeignKey target, so one relation can point at groupers of any type without hardcoding FKs:

# Conceptual shape of the generated through model
class BlogPostAuthorsRelation(OrderedCustomRelation):
    source = ForeignKey(BlogPost)               # the owning grouper
    content_type = ForeignKey(ContentType)      # which target type
    object_id = PositiveIntegerField()          # which target grouper
    target = GenericForeignKey(...)             # resolved target
    order = PositiveIntegerField()              # only when ordered=True

Because edges store the grouper’s stable primary key, they survive djangocms-versioning version copies untouched.

See Also