The previous post in this series showed how _inherit_detail lets an extension app inject fields and context into an existing detail page without ever importing or subclassing it. The same architecture a metaclass that registers a spec, and a composer that builds a dynamic mixin-based subclass extends to every other generic view type in Horilla CRM, including HorillaCardView, the tile/grid layout used for things like campaign boards or kanban-adjacent card grids.

If you’ve already read the detail view post, this one will feel familiar by design. That’s intentional: once you understand _inherit_detail, you already understand _inherit_card, _inherit_list, and _inherit_kanban they’re the same pattern, with a different set of layout attributes tuned to what that view actually renders.

The core idea, applied to cards

# horilla_crm/compliance/card_extensions.py

from horilla.extension.card import CardExtension
class LeadCardComplianceExtension(CardExtension):
    """Adds a compliance status column to the Lead card view."""
    _inherit_card = "horilla_crm.leads.views.LeadCardView"
    columns_append = [("Compliance", "compliance_status")]
    actions_append = [
        {
            "label": "Flag for Review",
            "url": "leads:flag_compliance",
            "perm": "leads.change_lead",
        }
    ]


    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["compliance_flagged_count"] = self.get_queryset().filter(
            is_compliance_flagged=True
        ).count()
        return context

Set _inherit_card on a CardExtension subclass, and that’s the entire registration step. CardExtension.__init_subclass__ fires the moment the class body executes, captures the spec, and (if the target is already importable) recomposes it immediately no explicit registration call, no URL change, no import of LeadCardView.

What gets captured from your class

register_card_extension_class() splits your class body into the same three buckets as detail extensions, just with a card-specific vocabulary:

  • Layout hooks — columns_insert, columns_append, bulk_update_fields_append, export_exclude_append, exclude_columns_append, actions_append, custom_bulk_actions_append, additional_action_button_append, exclude_quick_filter_fields_append, exclude_columns_from_sorting_append.
  • Method overrides — any plain method you define, like get_context_data above.
  • Scalar overrides — a fixed allowlist: filterset_class, default_sort_field, default_sort_direction, paginate_by, view_id, filter_url_push, enable_quick_filters, bulk_update_option, bulk_export_option, list_column_visibility, owner_filtration, enable_sorting, bulk_select_option, table_class, table_width, max_visible_actions.

That layout list should look familiar if you’ve read Horilla’s HorillaListView docs that’s because HorillaCardView is a HorillaListView subclass under the hood. It reuses the exact same queryset, filtering, and column machinery; the only real difference is the template it renders into (card_view.html, a tile grid, instead of a table). So extending a card view is really extending a list view whose layout happens to be cards.

How composition works

compose_card_view_class() mirrors the detail view composer almost exactly:

  1. Every registered spec for a target becomes a dynamically built mixin class — your methods and non-layout attrs, copied by reference.
  2. columns gets merged via the shared merge_columns() helper: columns_insert places a new column after a named anchor, columns_append adds to the end.
  3. The other append-style attributes (bulk_update_fields, export_exclude, exclude_columns, actions, custom_bulk_actions, additional_action_button, exclude_quick_filter_fields, exclude_columns_from_sorting) are merged the same way onto their corresponding target attribute.
  4. A new class is built with type(): ComposedName(Mixin1, …, Target), so overridden methods sit ahead of the original in the MRO and can call super() to chain into it — or into the next extension, if more than one app extends the same card view.
  5. The result is tagged __horilla_card_composed__ = True and cached in CARD_COMPOSED_MAP.

You never call compose_card_view_class() yourself. It runs automatically whenever the registry changes.

Why you still never touch urls.py

Because HorillaCardView inherits as_view() from HorillaListView, and that as_view() is already extension-aware for all three sibling view types it can represent list, card, and kanban. On every request it checks which kind of view the class actually is (issubclass(cls, HorillaCardView), HorillaKanbanView, or plain list), resolves the composed version through resolve_card_view_class(), and dispatches to that instead of the original, caching the resolved handler until the extension registry’s fingerprint changes.

This is what makes app load order irrelevant: your extension app can be installed and imported after the target CRM app’s URLs are already registered, and the card view will still pick up your extension on the very next request.

Priority and multiple extensions on one target

Like detail extensions, CardExtension supports _inherit_card_priority (an int, default 0) to control ordering when more than one app extends the same card view higher priority mixins sit closer to the front of the MRO. If two extensions both append to columns, both contributions show up; if two extensions override the same scalar (say, paginate_by), the merge logic resolves it deterministically based on priority, so you’re never guessing which app “wins.”

What this looks like end to end

Say horilla_crm.leads ships a plain LeadCardView:

class LeadCardView(HorillaCardView):
    model = Lead
    columns = [("Name", "full_name"), ("Status", "status")]

Your compliance app never touches this file. It just defines LeadCardComplianceExtension as shown above, in a module that gets imported at startup its own apps.py ready(), or an auto-imported registration.py (see the AppLauncher post for how auto_import_modules wires that up). The next request to the Lead card grid serves a composed class with three columns instead of two, an extra bulk action, and the compliance count injected into context all without a merge conflict against core code.

Extending a card view in Horilla follows the exact same declare-don’t-subclass model as extending a detail view: define a CardExtension with _inherit_card pointed at the target’s dotted path, list what you’re adding via the layout attributes, override any method you need with a normal super() call, and let the registry, composer, and extension-aware as_view() handle the rest. Because HorillaCardView sits directly on top of HorillaListView’s column and filtering machinery, everything you already know about extending list views carries over almost unchanged card views are just list views wearing a different template.

Share this article