Set Up Many-to-Many Relations

Relating content to content takes one line. Add a RelationField to a grouper model — it reads just like Django’s ManyToManyField:

from djangocms_custom_content.relations import RelationField

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

Run makemigrations and you’re done — post.authors.add(person) and person.authored_posts.all() both work, and the admin renders authors as a sortable autocomplete. The rest of this guide unpacks the options.

Why a custom field?

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. A single relation can also target groupers of any type, because storage uses a GenericForeignKey for the target.

Mental model: it is django-taggit, but the “tags” are grouper objects and each relation gets its own through table.

Declaring a relation

Declare RelationField on the grouper that owns the relation:

from djangocms_custom_content.models import AbstractCustomGrouper
from djangocms_custom_content.relations import RelationField

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

RelationField arguments:

target

A model class or an "app_label.Model" string. You may name either the grouper or its content model; both resolve to the grouper. The string form is resolved lazily, so the target app need not be importable at declaration time.

related_name

Installs a reverse accessor of this name on the target grouper. The target model does not import or declare anything — the reverse accessor simply appears once the app registry is ready.

ordered (default False)

Opt in to an explicit ordering column on the through table. Only then is a reorder() method available and results returned in stored order.

through_name (optional)

Override the auto-generated through model class name.

Each RelationField creates its own concrete through table with a uniqueness constraint (no duplicate edges) and an index for fast reverse lookups. No migration boilerplate is required beyond running makemigrations for the app that declares the field.

Using the accessors

The forward accessor lives on the owner grouper; the reverse accessor (if related_name was given) lives on the target grouper. Both return real querysets, so .filter(), .order_by(), .count() and friends just work.

post = BlogPost.objects.first()
person = Person.objects.first()

# Write API — accepts a grouper OR a content object (normalised to grouper)
post.authors.add(person)
post.authors.remove(person)
post.authors.set([person])
post.authors.clear()

# Read API — backed by a queryset of target groupers
post.authors.all()
post.authors.filter(...)
post.authors.count()
post.authors.exists()

# Reverse accessor invited by related_name
person.authored_posts.all()   # BlogPost groupers authored by this person

Ordered relations

When the field is declared with ordered=True, add() appends to the end and you can set an explicit order with reorder():

post.authors.reorder([alice, bob, carol])
post.authors.all()   # alice, bob, carol

reorder() raises TypeError on a relation that is not ordered.

In templates

Because accessors return groupers, reach the displayable content through the grouper’s get_admin_content (or get_content):

{% for person in post.authors.all %}
    {% with profile=person.get_admin_content %}
        <strong>{{ profile.name }}</strong>
    {% endwith %}
{% endfor %}

{% for category in post.categories.all %}
    <span class="category">{{ category.title }}</span>
{% endfor %}

Note

FlatCategory is a grouper-less content model, so its accessor yields the FlatCategory objects directly (category.title above), while Person is a grouper, so its content is reached via get_admin_content.

Deleting targets

The concrete source foreign key cascades natively. The generic target has no database constraint, so when a target grouper is deleted the framework sweeps its dangling relation rows automatically.

Copying

Edges are anchored to the grouper, so creating a new version copies nothing — the new version sees the same relations. They are only not carried over when you duplicate the grouper itself (just like a Django ManyToManyField); copy them explicitly in that case:

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())

A full duplicate usually also re-creates the grouper’s content object(s) (pointed at new_grouper) — a brand-new grouper has no content of its own.

See Relations Explained for the full rationale.

See Also