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:
targetA 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_nameInstalls 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(defaultFalse)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¶
Reference - API reference
Relations Explained - How the relation storage works
Model with M2M Relations - Step-by-step tutorial