Relations Explained

djangocms-custom-content models many-to-many relations between content with a single declarative field, RelationField, declared on a grouper model. This page explains how it works under the hood and why it is designed that way.

Two ideas make it correct and familiar

  1. Relations are anchored to the grouper’s stable primary key, never to a versioned content row. djangocms-versioning copies content into new rows with new primary keys; anchoring to the grouper means relations survive version copies untouched.

  2. It leans on the Django ORM. Storage is an ordinary through model with a concrete source ForeignKey and a GenericForeignKey target. The accessors return real querysets, so .filter(), .order_by(), .count() and friends just work.

The Through Model

A standard Django ManyToManyField generates an automatic through model with two concrete foreign keys — one to each side. That only works for relating to one specific model type.

Instead, relation_through_factory() builds a concrete through model per relation that uses:

  • one concrete ForeignKey (source) to the owning grouper, and

  • one GenericForeignKey (target, via content_type + object_id) that can point at a grouper of any type.

Column

Purpose

source

FK to the owning grouper (e.g. BlogPost)

content_type

Which grouper type the edge points at

object_id

Primary key of the target grouper

target

GenericForeignKey resolving content_type + object_id

order

Position column, only when ordered=True

Each through table carries a uniqueness constraint over (source, content_type, object_id) so an edge cannot be duplicated, plus an index on (content_type, object_id) for fast reverse lookups. Ordered relations use the OrderedCustomRelation base, which adds the indexed order column.

Grouper resolution

A RelationField target may name either a grouper or its content model. Both resolve to the grouper via grouper_model_of(), which finds the foreign key from a content model to its AbstractCustomGrouper. Likewise, add() / remove() accept either a grouper or a content instance and store the stable grouper primary key (grouper_of). A model with no grouper (such as the standalone FlatCategory) simply resolves to itself.

Forward and reverse accessors

Declaring the field installs a RelationManager as the forward accessor on the owner. If related_name is given, a ReverseRelationManager is installed on the target grouper once the app registry is ready — the target model imports or declares nothing.

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

post.authors.all()           # forward: Person groupers for this post
person.authored_posts.all()  # reverse: BlogPost groupers for this person

Both managers expose the same read API (all, filter, count, exists, iteration) and write API (add, remove, clear; the forward manager additionally has set and, for ordered relations, reorder). Reads always go through a genuine queryset of the related groupers.

Target-side cascade

The concrete source foreign key cascades natively on delete. The generic target has no database-level constraint, so a post_delete receiver sweeps dangling rows when a target grouper is deleted. It is cheap: skipped entirely unless relation tables exist, and it only runs for grouper instances.

Copying: versioning vs. duplicating a grouper

Because edges live on the grouper, there are two distinct cases — and only one of them needs your attention:

  • Creating a new version (djangocms-versioning copies the content row): the grouper keeps its primary key, so the edges are untouched and the new version sees the same relations. Nothing to do — this is the point of anchoring to the grouper.

  • Duplicating the grouper itself (a hand-rolled “duplicate this object” action that creates a new grouper): edges are not copied automatically, exactly as a Django ManyToManyField is not copied when you save an instance with pk=None. Copy them explicitly:

    from djangocms_custom_content.relations import iter_relation_fields
    
    for name, _field in iter_relation_fields(type(source_grouper)):
        getattr(new_grouper, name).set(getattr(source_grouper, name).all())
    

    .set() re-adds in queryset order, so ordered=True relations keep their positions.

    Copying edges is only half of it: a fresh grouper has no content, since the editable data lives in the content rows, not the grouper. A complete “duplicate this object” also re-creates the relevant content object(s) — typically one per language, or a new draft version — pointing their grouper foreign key at new_grouper before (or after) you copy the relations. How many content rows to copy is app-specific, so the framework leaves it to you.

See Also