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, anddashboard?”
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
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",
}]
When Django calls LeadsConfig.ready(), AppLauncher:
- Appends
/crm/leads/to root URLconf - Imports each module in
auto_import_modules - Merges Celery beat entries from
celery_schedules.py - 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",
)
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"],
}
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"]
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",
}
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
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
...
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],
})
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"),
]
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]
Checklist: new Horilla CRM app
- [ ]
apps.py—AppLauncher,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.py—app_nameset
Scaffold the skeleton:
python manage.py start_horilla_app my_module
Explore Horilla CRM
- GitHub: https://github.com/horilla/horilla-crm
- Website: https://www.horilla.com/crm/
- Live demo: https://crm.demo.horilla.com/
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)