A CRM record usually does not exist on its own. An Account can have Contacts, Opportunities, and Partners. A Campaign can have Members, while a Lead can have Activities and Notes.
When users open a record, they normally expect to see this related information without having to open several different pages. At the same time, building separate code for fetching, filtering, paginating, and displaying each type of related record can quickly become difficult to maintain.
Horilla CRM handles this using HorillaRelatedListSectionView and HorillaRelatedListContentView.
These views provide a common way to discover related records, display them using Horilla’s existing HorillaListView, and load the data when it is actually needed.
The implementation can be found in horilla/contrib/generics/views/related_list.py.
In this article, we will look at how related lists are discovered, how they are rendered, how they can be customized using related_list_config, and how Horilla handles relationships that use an intermediate model.
What Does HorillaRelatedListSectionView Do?
HorillaRelatedListSectionView is a DetailView subclass, but its purpose is slightly different from a normal detail view. Instead of displaying one object, it prepares a collection of related lists for that object.
It is normally used inside the “Related Lists” tab of a detail page and is loaded through HTMX.
The work is divided between two views:
- HorillaRelatedListSectionView finds the related lists and prepares their metadata, such as names, titles, and counts.
- HorillaRelatedListContentView loads the actual content of one selected related list.
This separation is useful for performance. The page can display the related-list tabs first without immediately running queries for every related model. The actual list is loaded when the user opens it.
How Related Lists Are Discovered Automatically
For a basic related-list implementation, a subclass only needs to define its model:
class LeadRelatedListsTab(LoginRequiredMixin, HorillaRelatedListSectionView):
model = Lead
When no custom configuration is provided, get_related_lists() checks the fields available on the model using:
obj._meta.get_fields()
Horilla then checks whether each field represents a valid related relationship.
The related field can be:
- A reverse ForeignKey relationship (one_to_many)
- A ManyToManyField
- A GenericRelation
This means that many common relationships are picked up automatically without requiring separate registration.
Horilla also ignores certain internal relationships, such as history and audit-log fields. If a particular application needs to ignore additional relationships, they can be added through excluded_related_lists.
This makes the default behavior simple: define the model and Horilla can discover most of its related records automatically.
Rendering Each List with HorillaListView
Once a related field has been identified, Horilla does not create another completely separate list-rendering system.
Instead, build_related_list_data() creates a HorillaListView instance for the related model.
The basic process looks like this:
related_manager = getattr(obj, field.name)
queryset = related_manager.all()
related_model = field.related_model
list_view = self.create_generic_list_view_instance(
model=related_model,
queryset=queryset[: self.max_items_per_list],
config=self.related_list_config.get(field.name, {}),
view_id=field.name,
parent_obj=obj,
)
The list is then rendered through the existing list-view machinery.
This is one of the useful parts of the implementation. Related lists do not need their own separate rendering logic. They reuse HorillaListView, which already handles things such as columns, actions, and permissions.
For a related list, some features such as bulk selection and filtering are disabled because the list is intended to be a compact view of records related to the current object rather than a complete module listing.
Customizing a Related List with related_list_config
Automatic discovery works well for simple relationships, but it is not always enough.
For example, a Campaign’s Members list may need a specific title, selected columns, or custom actions.
This is where related_list_config comes in.
A configuration can look like this:
class CampaignRelatedListsTab(LoginRequiredMixin, HorillaRelatedListSectionView):
model = Campaign
@cached_property
def related_list_config(self):
return {
"campaignmember_set": {
"title": _("Members"),
"columns": [
(_("Name"), "member_name"),
(_("Status"), "status"),
],
"actions": [
{
"action": "edit",
"permission": "campaigns.change_campaignmember",
"own_permission": "campaigns.change_own_campaignmember",
"owner_field": "created_by",
"attrs": """
hx-get="{get_edit_campaign_member}"
hx-target="#modalBox"
hx-swap="innerHTML"
onclick="event.stopPropagation();openModal()"
""",
},
],
}
}
Using @cached_property is particularly useful when the configuration depends on the current request. For example, custom buttons or actions may need to take the current user’s permissions into account.
Horilla supports both a normal dictionary and a cached_property for this configuration.
Some of the commonly used options are:
- title — Changes the title displayed for the related list.
- columns — Defines which columns should be displayed.
- actions and dropdown_actions — Adds row-level actions.
- can_add, add_url, and button_name — Controls the add button.
- custom_buttons — Adds additional buttons beside the add button.
- col_attrs — Adds HTML attributes to individual columns, such as HTMX links or permission checks.
This makes it possible to customize a related list without creating a separate view for it.
Custom Related Lists Through an Intermediate Model
Direct relationships are straightforward, but CRM applications often have more complicated relationships.
Consider Account and Contact.
A Contact may be connected to an Account through a ContactAccountRelationship model. That intermediate model can also contain additional information, such as the Contact’s role:
- Decision Maker
- Billing Contact
- Technical Contact
In this case, simply looking at the Account’s direct fields is not enough. The information about the relationship lives in the intermediate model.
Horilla supports this through the custom_related_lists configuration.
For example:
@cached_property
def related_list_config(self):
return {
"custom_related_lists": {
"contact_relationships": {
"app_label": "contacts",
"model_name": "Contact",
"intermediate_model": "ContactAccountRelationship",
"intermediate_field": "contact",
"related_field": "account",
"config": {
"title": _("Related Contacts"),
"columns": [
(_("First Name"), "first_name"),
(_("Last Name"), "last_name"),
(_("Role"), "account_relationships__role"),
],
},
},
}
}
The important part here is that the relationship is described through configuration instead of requiring another custom view.
How the Intermediate Relationship Is Resolved
Internally, Horilla handles this in a few steps.
First, it finds the intermediate records belonging to the current parent object:
intermediate_model.objects.filter(account=obj)
Next, it determines which records from the target model are connected through the intermediate model. It can then build a queryset containing the related Contact objects.
The interesting part is handling fields that belong to the intermediate model.
For example, account_relationships__role is not a normal field on Contact. The role belongs to the relationship record.
Horilla uses a correlated Subquery annotation to bring that value into the Contact queryset so that it can be displayed as a normal column.
The same approach can be used for other many-to-many-through relationships, such as Account → Partner.
If the declarative configuration is not enough for a particular case, there is also an escape hatch: a custom queryset callable can be provided.
Permission-Aware Row Actions
Permissions become slightly more complicated when a related list is displayed inside another record.
A child record should not necessarily inherit permissions simply because the user can access the parent.
Horilla handles this using _rewrite_actions_for_parent_permissions().
The behavior depends on the user’s access to the parent record.
If the user has global change or delete permission on the parent, the child permission restrictions are removed from the action. The action can therefore be displayed normally.
If the user only has own-level access to the parent, the action remains available but can be disabled when the related record belongs to someone outside the user’s allowed subordinate tree.
If the user has no access to the parent, the action is removed completely.
This is particularly important for CRM relationships where the intermediate model represents something created or managed by a specific user.
The related-list configuration therefore does not need to contain all of the ownership logic itself. It can describe the relationship, while the related-list view applies the appropriate permission rules when the list is rendered.
Loading One Related List at a Time
A detail page can potentially have many related models.
Loading every related list immediately could result in unnecessary database queries, especially when the user only wants to look at one of them.
This is where HorillaRelatedListContentView becomes useful.
The section view can prepare the related-list tabs, while the content view handles the actual content for the selected list.
It resolves the parent object and related model and then checks the related-list view registry:
def get_parent_view_class(self, model, class_name):
view_class = HorillaRelatedListSectionView._view_registry.get(model)
return view_class or HorillaRelatedListSectionView
Related-list subclasses are automatically registered through __init_subclass__.
Once the correct section view is found, the content view calls:
get_single_related_list(obj, field_name)
Only the requested related list is then rendered.
This keeps the initial detail-page load lighter and avoids doing unnecessary work for related lists that the user never opens.
Wiring Related Lists Into a Detail Page
Adding a related-list tab to a detail page is relatively simple.
For example:
urlpatterns = [
path(
"leads/<int:pk>/related-lists/",
LeadRelatedListsTab.as_view(),
name="lead_related_lists",
),
]
The detail tab configuration can then reference this URL:
self.urls = {
"details": "leads:leads_details_tab",
"related_lists": "leads:lead_related_lists",
}
HorillaDetailTabView uses this configuration to create the tab.
When the user opens the Related Lists tab, Horilla displays the related-list headers and counts. When a specific list is selected, such as Activities, Contacts, or Opportunities, HorillaRelatedListContentView loads that list’s content.
The result is a detail page where related information feels like part of the same record rather than a collection of separate pages.
Why This Approach Works Well
There are several advantages to the way Horilla handles related lists.
Zero configuration for simple relationships
Reverse foreign keys, many-to-many relationships, and generic relations can be discovered automatically.
Reuse of HorillaListView
Related lists use the same list-view system as normal module pages. This means there is no need to maintain a second rendering system for columns, actions, and permissions.
Support for intermediate relationships
The custom_related_lists configuration makes it possible to handle relationships through join models without adding relationship-specific view code.
Permission-aware actions
Related-list actions are evaluated in the context of the parent record, which helps prevent incorrect access to child records.
Lazy loading
Related-list content is loaded separately through HTMX, so the page does not need to query and render every related model when the detail page is first opened.
Related lists may look like a small part of a CRM detail page, but implementing them properly involves quite a few things: discovering relationships, building querysets, rendering lists, handling permissions, supporting intermediate models, and keeping the page responsive.
Horilla’s HorillaRelatedListSectionView brings these pieces together without creating a separate implementation for every relationship.
Simple relationships can be discovered automatically, while more complicated relationships can be described using related_list_config and custom_related_lists. The actual list rendering is delegated to the existing HorillaListView, and HTMX keeps the related content loading lightweight.
The biggest advantage is that adding a new related list usually becomes a configuration task rather than a new view-development task. Once the discovery and rendering pattern is understood, the same approach can be reused across different CRM modules while keeping the code consistent and easier to maintain.