Every CRM eventually runs into the same wall: the built-in Lead or Contact detail page is almost right, but not quite. Maybe you need an extra field pulled from a related model, a custom badge next to the status, or a whole new section for compliance data your team tracks. The obvious fix opening views/details.py in the core app and editing it directly works until the next upgrade overwrites your changes.
Horilla CRM solves this with its extension app system: a metaclass-driven composition layer that lets you inject behavior into an existing HorillaDetailView from a completely separate app, without subclassing it, importing it, or touching a single line of the original file. Under the hood, Horilla actually uses this exact mechanism itself the custom_fields app injects custom-field values into every opted-in model’s detail view this way, and it never imports the target view by name.
This post walks through how _inherit_detail extensions work and how to write your own.
The core idea: declare, don’t subclass
Normally, extending a Django class-based view means subclassing it and overriding methods which only works if you control the URL routing, or if you’re willing to monkey-patch. Horilla flips this around with DetailExtension:
# horilla_crm/compliance/detail_extensions.py
from horilla.extension.detail import DetailExtension
class LeadComplianceExtension(DetailExtension):
"""Adds a compliance status field to the Lead detail view."""
_inherit_detail = "horilla_crm.leads.views.LeadDetailView"
body_append = ["compliance_status"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["compliance_flagged"] = getattr(
context.get("obj"), "is_compliance_flagged", False
)
return context
That’s the whole extension. No URL changes, no import of LeadDetailView, no registration call. Setting _inherit_detail on a DetailExtension subclass is enough the metaclass hook (__init_subclass__) fires the moment this class body executes, registers a DetailExtensionSpec, and triggers recomposition automatically.
What happens when this class is defined
- DetailExtension.__init_subclass__ sees _inherit_detail is set and calls register_detail_extension_class(cls).
- That function reads three kinds of contributions off your class:
- Layout hooks — body_insert, body_append, fieldsets_insert, header_fields_insert/_append, excluded_fields_append, actions_append, badge_append, breadcrumbs_append.
- Method overrides — any plain method you define, like get_context_data above.
- Scalar overrides — a fixed allowlist (pipeline_field, tab_url, template_name, context_object_name).
- It stores all of this as a DetailExtensionSpec keyed by the target’s dotted path (“horilla_crm.leads.views.LeadDetailView” — a string, deliberately, so the target module doesn’t need to be imported yet).
- It immediately tries to compose the target, in case it’s already importable.
How the target view actually gets extended
The real work happens in compose_detail_view_class():
- Every registered extension for a target becomes a dynamically generated mixin class (just your methods and attrs, copied by reference).
- Layout lists are merged: body_append values are appended to the target’s body, body_insert pairs are inserted after a named anchor field, fieldsets_insert slots a field into the right fieldset, and so on.
- A new class is built with type(): ComposedName(Mixin1, Mixin2, …, Target) so your overridden get_context_data sits before the original in the MRO and can call super().get_context_data() to chain into it (or into the next extension, if more than one app extends the same view).
- The composed class is tagged __horilla_detail_composed__ = True and cached in DETAIL_COMPOSED_MAP.
You never call any of this yourself it’s why _inherit_detail extensions read like declarations, not wiring code.
Why you never touch urls.py
This is the part that makes the system genuinely painless: HorillaDetailView.as_view() is itself extension-aware. On every incoming request it calls resolve_detail_view_class(cls), which checks whether a composed version of the view exists and dispatches to that instead of the original. If your extension app happens to load after the target app’s URLs are already registered (a common ordering problem with Django apps), it doesn’t matter resolution happens per-request, not at import time, and the result is cached until the extension registry’s fingerprint changes.
In short: define the DetailExtension subclass anywhere it gets imported at startup (its own apps.py ready(), or an auto-imported module like registration.py), and the target view picks it up automatically the next time it’s requested.
A closer look at layout hooks
| Attribute | Effect |
| body_append | Adds field names to the end of the detail body |
| body_insert | [(after_field, new_field), …] : inserts a field right after an anchor |
| fieldsets_insert | Same insert-after-anchor semantics, but scoped to a specific fieldset |
| header_fields_append / _insert | Same idea, for the header summary block |
| excluded_fields_append | Adds fields to the model’s exclusion list |
| actions_append | Adds new row-level actions to the detail page |
| badge_append | Adds a badge next to the object’s status/title |
| breadcrumbs_append | Extends the breadcrumb trail |
Because these are declared as plain list attributes rather than methods, multiple unrelated extension apps can each contribute to the same view without needing to know about each other Horilla merges every registered spec for a target in one pass.
Extending a detail section the same way
Detail pages are often split into tabs or sections (HorillaDetailSectionView), and Horilla mirrors the exact same mechanism for those via DetailSectionExtension and _inherit_detail_section:
from horilla.extension.detail_section import DetailSectionExtension
class LeadComplianceSectionExtension(DetailSectionExtension):
_inherit_detail_section = "horilla_crm.leads.views.LeadActivitySection"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["compliance_notes"] = self.object.compliance_notes
return context
Same registration flow, same composition logic, just scoped to DETAIL_SECTION_EXTENSION_REGISTRY and its own resolver.
A real example from Horilla’s own codebase
You don’t have to take this on faith Horilla’s custom_fields app extends every model’s detail view that has opted into custom fields, entirely through this system. It never imports a single concrete detail view by name. Instead, it:
- Reads which models opted in via FEATURE_REGISTRY[“custom_fields_models“].
- Looks up each model’s registered view class from HorillaDetailView._view_registry.
- Dynamically builds a DetailExtension subclass per view, with _inherit_detail set to that view’s path, and a get_context_data override that merges custom-field values into the context.
- Registers a pre-compose hook so that if a CRM app registers its model after custom_fields has already loaded, the extension still gets created before the view is composed.
That last point matters for your own extensions too: if you’re extending a view whose registration order relative to yours isn’t guaranteed, use horilla.extension._pre_compose_hooks.register_pre_compose_hook(“detail”, your_discovery_fn) instead of hardcoding the target path at import time.
Extending a detail view in Horilla isn’t about subclassing or monkey-patching it’s about declaring a DetailExtension with _inherit_detail pointed at the target view’s dotted path, and letting the framework’s metaclass, registry, and composer do the rest. Your app never imports the target view, the target app never knows your extension exists, and URL routing keeps working unmodified because as_view() resolves the composed class on every request.
This is the same pattern Horilla’s own custom_fields module relies on to inject fields into arbitrary detail views across the whole CRM which is a good sign that it holds up under real, multi-app composition, not just a single toy example. If your next customization is “I need one more field or section on an existing detail page,” this is the mechanism to reach for instead of forking core.