Sooner or later, every Horilla implementation needs to add something to a list view that already exists a custom column for one client, an extra bulk-update field, an additional row action without touching the core app that defines it. Editing horilla_crm/leads/views/core.py directly works until the next upgrade overwrites your changes. What you actually want is to extend the existing view from a separate app, the same way Odoo lets you inherit and modify a model without editing its source.
Horilla CRM solves this with the _inherit_list mechanism: a small, self-contained extension framework built specifically for HorillaListView, living in horilla/extension/list/. It lets a completely separate Django app register a ListExtension against an existing list view or even against HorillaListView itself and have it composed in automatically, with no edits to the original view and no changes to INSTALLED_APPS ordering to get right.
What Does the Extension Mechanism Do?
At a high level, _inherit_list lets you:
- Add columns to an existing list view, inserted at a specific position or appended at the end
- Add fields to the bulk-update form, export exclusions, or additional action buttons
- Override any method on the view get_queryset, get_context_data, and so on using ordinary super() calls
- Target one concrete view (e.g. LeadListView) or the shared HorillaListView base class, so a single extension can apply to every list view in the codebase at once
Under the hood, this works by dynamically composing a new class from the original view plus one mixin per registered extension and swapping it in at request time, so the URL configuration never has to know an extension exists.
Defining a List Extension
An extension is just a class that inherits from ListExtension and sets _inherit_list to the dotted path of the view it’s extending:
svg
my_lead_extensions/lists.py
from horilla.extension.list import ListExtension
class LeadListExtension(ListExtension):
_inherit_list = "horilla_crm.leads.views.core.LeadListView"
_inherit_list_priority = 0
columns_insert = [("industry", "industry_code")]
bulk_update_fields_append = ["industry_code"]
That’s it no metaclass wiring, no manual registry call. The moment this class body executes, ListExtension.__init_subclass__ notices _inherit_list is set and registers the extension automatically.
Understanding the Configuration
A few things are worth calling out:
- _inherit_list — the dotted import path of the target view, as a string (not the class itself). This avoids import-order problems: the extension module doesn’t need the target view imported at load time.
- _inherit_list_priority — when multiple extensions target the same view, this controls stacking order.
- Layout hooks (columns_insert, columns_append, bulk_update_fields_append, export_exclude_append, actions_append, custom_bulk_actions_append, and several others) — these are declarative, additive changes. The rule from Horilla’s own docs is explicit: “Use *_insert / *_append hooks — do not patch core columns in place.” columns_insert takes (after_key, new_key) pairs so you can place a new column relative to an existing one, rather than always appending to the end.
- Method overrides — anything not covered by a layout hook, you override directly as a normal Python method, calling super() exactly as you would in any subclass:
class LeadListExtension(ListExtension):
_inherit_list = "horilla_crm.leads.views.core.LeadListView"
def get_queryset(self):
queryset = super().get_queryset()
return queryset.select_related("industry_code")
This works correctly even when several extensions stack on the same view Horilla rebinds each extension method’s __class__ closure cell at composition time specifically so that a plain, zero-argument super() resolves through the whole extension chain instead of raising a TypeError.
Registering the Extension App
Extension apps in Horilla follow the same auto_import_modules convention used everywhere else in the codebase there’s no special folder name required, just a module named after the extension kind, listed on the app’s AppConfig:
# my_lead_extensions/apps.py
from horilla.apps import AppLauncher
class MyLeadExtensionsConfig(AppLauncher):
name = "my_lead_extensions"
auto_import_modules = ["models", "forms", "lists", "kanbans", "details"]
AppLauncher.ready() imports each of these modules for you. Importing lists.py executes the LeadListExtension class body, which triggers registration as a side effect.
Then the app just needs to be added to INSTALLED_APPS, typically in a client-owned settings file rather than the core app list:
svg
# local_settings.py
INSTALLED_APPS += [“my_lead_extensions”]
It’s worth noting that ordering relative to horilla_crm doesn’t matter here. HorillaListView.as_view() doesn’t decide which class to run at import time it resolves the concrete class on every incoming request, specifically so that extension apps loading after their target app are still picked up correctly.
How Resolution Actually Happens
This is the part that makes the whole thing safe to bolt on without touching existing URLs. LeadListView.as_view() in urls.py never changes but HorillaListView.as_view() wraps the real view in a resolver:
def view(request, *args, **kwargs):
resolved = resolve_list_view_class(cls)
if resolved is not cls:
if (
getattr(view, "_extended_handler", None) is None
or getattr(view, "_extended_cls", None) is not resolved
):
view._extended_cls = resolved
view._extended_handler = resolved.as_view(**initkwargs)
return view._extended_handler(request, *args, **kwargs)
return base_view(request, *args, **kwargs)
resolve_list_view_class() checks whether any ListExtension has registered against this exact class; if one has, it returns a dynamically composed class instead — LeadListView plus one mixin per extension, in an MRO like:
svg
ComposedLeadListView -> LeadListExtension -> LeadListView -> HorillaListView -> ListView
The result is cached per class, so this composition happens once, not on every request.
Targeting the Shared Base Class
There’s another important detail, and it’s the most powerful part of the mechanism: _inherit_list doesn’t have to name a specific view. It can name HorillaListView itself, and the extension will apply to every list view in the entire codebase, present and future, without registering against each one individually.
Horilla’s own custom_fields app does exactly this, to inject custom-field values into every list view’s context:
from horilla.extension.list import ListExtension
from custom_fields.list_hooks import attach_custom_fields_to_list_context
class CustomFieldListContextExtension(ListExtension):
"""Attach custom-field values and sorting exclusions to every list view."""
_inherit_list = "horilla.contrib.generics.views.list.HorillaListView"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
attach_custom_fields_to_list_context(self, context)
except Exception:
import logging
logging.getLogger(__name__).exception(
"custom_fields: could not attach list custom fields"
)
return context
The reasoning, straight from that module’s own comments: registering _inherit_list against every concrete list view individually would mean custom_fields needs to know about every list view in every app in advance, “the opposite of generic, not hardcoded.” Instead, the resolver walks the MRO: if there’s no exact registration for LeadListView, it checks LeadListView’s base classes, finds the registration on HorillaListView, and applies it. Any list view that inherits from HorillaListView which is all of them picks this up automatically.
This base-class fallback is specific to the list mechanism; it isn’t available for tab extensions, where the target must be a concrete HorillaTabView subclass.
Extending Bulk Actions and Buttons
The same additive pattern extends past columns. Adding a custom bulk action or an extra row-level action button follows the same layout-hook convention:
class LeadListExtension(ListExtension):
_inherit_list = "horilla_crm.leads.views.core.LeadListView"
custom_bulk_actions_append = [
{
"label": "Reassign Owner",
"action": "reassign_owner",
},
]
additional_action_button_append = [
{
"label": "Send Reminder",
"icon": "notifications-outline",
},
]
As with columns, these are appended rather than overwriting the base view’s custom_bulk_actions / additional_action_button lists so multiple extension apps can add their own actions to the same view without stepping on each other.
A Note on the Wider Extension Family
_inherit_list isn’t a one-off it’s one of twelve parallel extension kinds Horilla ships (list, tab, card, kanban, detail, detail_section, filter, forms, formatting, mixin, nav, view), each with its own registry.py / metaclass.py / compose.py / resolve.py under horilla/extension/<kind>/, all composed together once at startup through a single bootstrap_extensions() call. If you’ve already used _inherit_tab to extend a HorillaTabView (see the earlier post on HorillaTabView), the shape here will feel immediately familiar same __init_subclass__ registration, same per-request resolution at as_view(), same dynamic MRO composition. The main difference is that list extensions add a set of declarative layout hooks (columns_insert, *_append, scalar overrides) on top of plain method overrides, and support the base-class fallback described above.
Extending a HorillaListView from a separate app comes down to three steps: define a ListExtension subclass with _inherit_list pointing at your target (a specific view, or HorillaListView for a codebase-wide change), list its module in auto_import_modules so it gets picked up on app startup, and add the app to INSTALLED_APPS. Whether you’re inserting a single custom column for one client or attaching shared context to every list view in the system, the mechanism is the same and none of it requires touching the view you’re extending.