DEV Community

Cover image for Inside a Horilla CRM App: registration.py, menu.py, and What AppLauncher Actually Loads
Horilla
Horilla

Posted on

Inside a Horilla CRM App: registration.py, menu.py, and What AppLauncher Actually Loads

Horilla CRM Code Walkthrough

Previous: Building a Plugin-Based Django Architecture with Horilla’s AppLauncher


TL;DR: AppLauncher loads your app at startup. These four files are what actually wire it into Horilla CRM: registration.py (features + permissions), menu.py (sidebar & FAB), signals.py (cross-module events), and dashboard.py (charts). Skip registration.py and your model is invisible to global search, import, export, and workflows.


In last post on AppLauncher, we saw how a Horilla CRM app declares url_prefix, auto_import_modules, and Celery schedules — then integrates without touching the root urls.py.

The next question every developer asks:

“What runs when AppLauncher imports registration, menu, signals, and dashboard?”

This post answers that using the Leads module (horilla_crm/leads) — the reference implementation for every CRM app in Horilla CRM.


The standard app folder

Every production module follows the same layout:

horilla_crm/leads/
├── apps.py              # AppLauncher config
├── registration.py      # Feature + platform hooks  ← critical
├── menu.py              # Sidebar / FAB / settings menus
├── signals.py           # Cross-module event handlers
├── dashboard.py         # Dashboard chart generators
├── models/              # HorillaCoreModel subclasses
├── views/               # horilla_generics CBVs
├── forms.py             # HorillaModelForm / HorillaMultiStepForm
├── filters.py           # HorillaFilterSet
├── urls.py              # Namespaced routes (app_name = "leads")
├── api/                 # DRF serializers + router
├── celery_schedules.py  # Beat tasks (merged by AppLauncher)
└── templates/           # HTMX partials
Enter fullscreen mode Exit fullscreen mode

You declare integration in apps.py and the convention files above — not in a central project config file.


1. apps.py — the integration contract

from horilla.apps import AppLauncher
from horilla.utils.translation import gettext_lazy as _


class LeadsConfig(AppLauncher):
    default = True
    name = "horilla_crm.leads"
    verbose_name = _("Leads")

    url_prefix = "crm/leads/"
    url_module = "horilla_crm.leads.urls"
    url_namespace = "leads"

    auto_import_modules = [
        "registration",
        "signals",
        "menu",
        "dashboard",
    ]

    celery_schedule_module = "celery_schedules"
    celery_schedule_variable = "HORILLA_CRM_BEAT_SCHEDULE"

    def get_api_paths(self):
        return [{
            "pattern": "crm/leads/",
            "view_or_include": "horilla_crm.leads.api.urls",
            "name": "horilla_crm_leads_api",
            "namespace": "horilla_crm_leads",
        }]
Enter fullscreen mode Exit fullscreen mode

When Django calls LeadsConfig.ready(), AppLauncher:

  1. Appends /crm/leads/ to root URLconf
  2. Imports each module in auto_import_modules
  3. Merges Celery beat entries from celery_schedules.py
  4. Registers API routes from get_api_paths()

auto_import_modules is the bridge between AppLauncher and the convention files below .


2. registration.py — the most important file

Without this file, your model exists in PostgreSQL but Horilla’s platform does not know about it: no global search, no import/export, no duplicate merge, no approvals, no scoring.

Feature registry

horilla/registry/feature.py maintains:

  • FEATURE_CONFIG — maps feature names to registry keys ("global_search""global_search_models")
  • FEATURE_REGISTRY — maps keys to lists of model classes

Registration happens at import time:

from horilla.registry.feature import register_model_for_feature
from horilla.contrib.cadences.registration import register_cadence_tab

register_model_for_feature(
    app_label="leads",
    model_name="LeadStatus",
    features=["import_data", "export_data", "global_search"],
)

register_model_for_feature(
    app_label="leads",
    model_name="Lead",
    all=True,
    features=[
        "duplicate_models",
        "approval_models",
        "reviews_models",
        "workflow_models",
        "scoring",
    ],
)

register_cadence_tab(
    app_label="leads",
    model_name="Lead",
    url_prefix="lead-cadences-tab/<int:pk>/",
    url_name="lead_cadences_tab",
)
Enter fullscreen mode Exit fullscreen mode

features=[...] vs all=True

Style When to use
features=["import_data", "global_search"] Lookup tables, config models
all=True Primary business entities (Lead, Opportunity, Account)
register_feature(...) Define a new platform capability other apps can opt into

Rule of thumb: If users work with it daily, use all=True (with exclude= if needed). If it’s a status or config row, list features explicitly.

Why this matters for permissions

Feature registration is tied to permission generation and UI discovery. Skipping registration.py is the #1 mistake when adding a new module — the app “works” in isolation but vanishes from search, exports, and workflows.


3. menu.py — navigation without editing base templates

Horilla collects menus via decorators in horilla/menu/. Your app only ships menu.py; the layout renders entries at runtime.

Floating action button (quick create)

from horilla.urls import reverse_lazy
from horilla.menu import floating_menu
from .models import Lead

@floating_menu.register
class LeadFloating:
    title = Lead()._meta.verbose_name
    url = reverse_lazy("leads:leads_create")
    icon = "/assets/icons/leads.svg"
    items = {
        "hx-target": "#modalBox",
        "hx-swap": "innerHTML",
        "onclick": "openModal()",
        "perm": ["leads.add_lead"],
    }
Enter fullscreen mode Exit fullscreen mode

Sidebar: main section + sub-section

from horilla.menu import (
    main_section_menu,
    sub_section_menu,
    MAIN_CONTENT_HX_ATTRS,
)
from horilla.utils.translation import gettext_lazy as _

@main_section_menu.register
class SalesSection:
    section = "sales"
    name = _("Sales")
    icon = "/assets/icons/sales.svg"
    position = 1

@sub_section_menu.register
class LeadSubSection:
    section = "sales"
    app_label = "leads"
    position = 1
    verbose_name = _("Leads")
    icon = "/assets/icons/leads.svg"
    url = reverse_lazy("leads:leads_view")
    attrs = MAIN_CONTENT_HX_ATTRS
    perm = ["leads.view_lead", "leads.view_own_lead"]
Enter fullscreen mode Exit fullscreen mode

Menu registry types

Registry Purpose
main_section_menu Top-level sidebar (Sales, Marketing, …)
sub_section_menu Links under a section
floating_menu Quick-create FAB
settings_menu Module settings area
my_settings_menu Per-user preferences

HTMX-aware navigation

MAIN_CONTENT_HX_ATTRS = {
    "hx-boost": "true",
    "hx-target": "#mainContent",
    "hx-select": "#mainContent",
    "hx-swap": "outerHTML",
}
Enter fullscreen mode Exit fullscreen mode

Sub-section links swap #mainContent instead of reloading the full page — fast, server-rendered navigation without React.

Menu items support "perm" so entries hide for unauthorized users.


4. signals.py — platform events, not only post_save

Leads uses Django receivers and Horilla CRM-wide signals:

Signal Purpose
company_created Seed default lead stages for new tenant
company_currency_changed Bulk-update MoneyField on leads
lead_stage_created Opportunity pipeline setup

Real handler when a new company is created:

from django.dispatch import receiver
from horilla.contrib.core.signals import company_created, company_currency_changed
from horilla.urls import reverse_lazy
from horilla.shortcuts import render

@receiver(company_created)
def handle_company_created(sender, instance, request, view, is_new, **kwargs):
    if is_new:
        url = reverse_lazy("leads:load_lead_stages", kwargs={"company_id": instance.id})
        return render(
            request,
            "lead_status/reload_and_load_url_script.html",
            {"load_url": str(url)},
        )
    return None
Enter fullscreen mode Exit fullscreen mode

Currency change updates all leads for that company:

@receiver(company_currency_changed)
def update_crm_on_currency_change(sender, **kwargs):
    company = kwargs.get("company")
    conversion_rate = kwargs.get("conversion_rate")
    leads_to_update = []
    for lead in Lead.objects.filter(company=company).only("id", "annual_revenue"):
        if lead.annual_revenue is not None:
            # apply conversion_rate, collect for bulk_update
            ...
Enter fullscreen mode Exit fullscreen mode

Convention: Keep signals.py thin — heavy work goes to methods.py or Celery tasks.


5. dashboard.py — charts and KPIs

Dashboards are not hard-coded in core. Apps register generators on DefaultDashboardGenerator:

from horilla.db.models import Count
from horilla.contrib.dashboard.utils import DefaultDashboardGenerator
from .models import Lead

def create_lead_source_charts(self, queryset, model_info):
    data = queryset.values("lead_source").annotate(count=Count("id")).order_by("-count")
    if not data.exists():
        return None
    labels = [item["lead_source"] or "Unknown" for item in data]
    values = [item["count"] for item in data]
    return {
        "title": "Leads by Source",
        "type": "funnel",
        "data": {"labels": labels, "data": values, "urls": urls},
    }

DefaultDashboardGenerator.extra_models.append({
    "model": Lead,
    "name": "Leads",
    "kpi_func": lead_kpi_cards,
    "chart_func": [create_lead_source_charts, create_lead_charts_by_stage],
    "table_func": [lead_convert_table_func, lead_open_pipeline_table_func],
})
Enter fullscreen mode Exit fullscreen mode

When a user builds a dashboard around Leads, Horilla calls these functions and renders ECharts JSON — clickable segments can deep-link to filtered list views.


6. urls.py — namespaced, HTMX-friendly routes

from horilla.urls import path
from horilla_crm.leads import views

app_name = "leads"

urlpatterns = [
    path("leads-view/", views.LeadView.as_view(), name="leads_view"),
    path("leads-list/", views.LeadListView.as_view(), name="leads_list"),
    path("leads-kanban/", views.LeadKanbanView.as_view(), name="leads_kanban"),
    path("leads-detail/<int:pk>/", views.LeadDetailView.as_view(), name="leads_detail"),
]
Enter fullscreen mode Exit fullscreen mode

Always reverse with the namespace: reverse("leads:leads_list").

List, kanban, and detail are separate endpoints — the shell swaps HTMX targets between them (covered on Day 5 and Day 7).


Startup flow (end to end)

flowchart TD
    A[Django loads INSTALLED_APPS] --> B[LeadsConfig.ready]
    B --> C[Register /crm/leads/ URLs]
    B --> D[import registration.py]
    B --> E[import menu.py]
    B --> F[import signals.py]
    B --> G[import dashboard.py]
    D --> H[FEATURE_REGISTRY populated]
    E --> I[Menu decorators collected]
    F --> J[Signal handlers connected]
    G --> K[Dashboard generators registered]
Enter fullscreen mode Exit fullscreen mode

Checklist: new Horilla CRM app

  • [ ] apps.pyAppLauncher, auto_import_modules, url_prefix
  • [ ] registration.py — every user-facing model registered
  • [ ] menu.py — at least one sidebar or FAB entry
  • [ ] signals.py — if you handle company/currency/stage events
  • [ ] dashboard.py — if the module appears on dashboards
  • [ ] urls.pyapp_name set

Scaffold the skeleton:

python manage.py start_horilla_app my_module
Enter fullscreen mode Exit fullscreen mode

Explore Horilla CRM


Have you built plugin-style Django apps before? What would you add to auto_import_modules beyond these four files? Drop a comment — I read every one.

Top comments (0)