A Horilla CRM app is a self-contained Django module that plugs into the platform through AppLauncher. Each app ships convention files that register features, menus, signals, and dashboards without editing the root urls.py.
This post is Part 1 of 28 in the Horilla CRM Technical Blog series.
What is a Horilla CRM App Module?
A Horilla CRM app module enables you to:
- apps.py — self-register URLs, APIs, and Celery schedules
- registration.py — expose models to search, import, export, and workflows
- menu.py — add sidebar and quick-create entries
- signals.py — react to company and currency events
- dashboard.py — contribute dashboard charts
The Standard App Folder
Every production Horilla CRM app 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.contrib.generics CBVs
│ ├── core.py # list, kanban, nav, shell
│ ├── detail_tabs.py # activity, history, related-list tabs
│ └── …
├── 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 do not edit the root urls.py for each new app. You declare integration in apps.py and the convention files AppLauncher imports.
apps.py — The Integration Contract
horilla_crm/leads/apps.py
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",
}]
Key idea: auto_import_modules is the bridge between AppLauncher and the convention files. When Django calls ready(), Horilla imports each module — side effects run automatically.
registration.py — Plug Your Model into the Platform
This is the most important file in a new app.
Without registration.py, your model exists in the database, but Horilla’s platform features do not know about it: global search, import/export, duplicates, approvals, workflows, scoring, and more.
How the Feature Registry Works
horilla/registry/feature.py maintains two central structures:
- FEATURE_CONFIG — maps a feature name to a registry key
- FEATURE_REGISTRY — maps registry keys to lists of model classes
horilla_crm/leads/registration.py
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//",
url_name="lead_cadences_tab",
)
features=[…] vs all=True
| Style | When to use |
| features=[“import_data”, “global_search”] | Only specific platform capabilities |
| all=True | Opt into every registered feature, with optional exclude=[…] |
| register_feature(…) in your app | Define a custom feature other apps can opt into |
Rule of thumb: Primary business models (Lead, Opportunity, Account) use all=True. Lookup/status models often use an explicit feature list.
Why This Matters for Permissions
Feature registration is tightly coupled to Horilla’s permission and UI discovery systems. Skipping registration.py is the #1 mistake when adding a new CRM module — the app “works” in isolation but disappears from search, exports, and cross-cutting workflows.
menu.py — Navigation Without Editing Base Templates
Horilla uses decorator-based menu registries in horilla/menu/. Apps declare menus in menu.py; the layout renders them at runtime.
horilla_crm/leads/menu.py (excerpt)
from horilla.urls import reverse_lazy
from horilla.menu import floating_menu, main_section_menu, settings_menu, MAIN_CONTENT_HX_ATTRS
from horilla.utils.translation import gettext_lazy as _
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: Sales Section + Leads Sub-menu
@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"
verbose_name = _("Leads")
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 sections (Sales, Marketing, …) |
| sub_section_menu | Items under a main section |
| floating_menu | Quick-create FAB entries |
| settings_menu | Module settings in the Settings area |
| my_settings_menu | Per-user preferences |
HTMX-Aware Navigation
MAIN_CONTENT_HX_ATTRS standardizes in-app navigation:
MAIN_CONTENT_HX_ATTRS = {
"hx-boost": "true",
"hx-target": "#mainContent",
"hx-select": "#mainContent",
"hx-swap": "outerHTML",
}
Apply these on sub-section links so page transitions swap #mainContent instead of reloading the full shell. Menu items can declare “perm“: “leads.view_lead” so entries hide automatically for unauthorized users.
signals.py — Platform Events, Not Only post_save
signals.py holds standard Django receivers and Horilla CRM custom signals.
CRM custom signals (examples):
- company_created — initialize stages, defaults for a new tenant
- company_currency_changed — bulk-update money fields across modules
- lead_stage_created — pipeline setup side effects
@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
@receiver(company_currency_changed)
def update_crm_on_currency_change(sender, **kwargs):
company = kwargs.get("company")
# bulk_update Lead.annual_revenue for company
…
Convention: Keep signal modules thin — delegate heavy logic to methods.py or Celery tasks.
dashboard.py — Chart Discovery
Dashboard widgets are not hard-coded in horilla.contrib.dashboard. Apps contribute generators via dashboard.py:
def create_lead_source_charts(self, queryset, model_info):
data = queryset.values("lead_source").annotate(count=Count("id"))
return {
"title": "Leads by Source",
"type": "funnel",
"data": {"labels": labels, "data": values, "urls": urls},
}
DefaultDashboardGenerator.extra_models.append({
"model": Lead,
"name": "Leads",
"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 opens a dashboard filtered to Leads, Horilla calls these builders and renders ECharts — another convention over configuration win.
urls.py — Namespaced, HTMX-Friendly Routes
horilla_crm/leads/urls.py (excerpt)
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//", views.LeadDetailView.as_view(), name="leads_detail"),
…
]
Always use app_name and reverse with the namespace: reverse(“leads:leads_list”). List, kanban, card, and detail views are separate endpoints — the shell swaps HTMX targets between them.
End-to-End: What Happens at Startup
When Django loads INSTALLED_APPS, the following sequence runs:
| ① | Django loads INSTALLED_APPS → LeadsConfig.ready() called |
| ② | Register /crm/leads/ URL prefix and namespace |
| ③ | import registration.py → FEATURE_REGISTRY populated |
| ④ | import menu.py → menu decorators collected |
| ⑤ | import signals.py → signal handlers connected |
| ⑥ | import dashboard.py → chart generators registered |
Checklist for a New Horilla CRM App
- ☐ apps.py extends AppLauncher with url_prefix, url_module, auto_import_modules
- ☐ registration.py registers every user-facing model
- ☐ menu.py registers at least main_section_menu or sub_section_menu
- ☐ signals.py if you react to company/currency/stage events
- ☐ dashboard.py if the module appears on dashboards
- ☐ urls.py with app_name
- ☐ Models extend HorillaCoreModel (Part 2)
- ☐ Views use horilla.contrib.generics (Part 3)
Scaffold everything with:
python manage.py start_horilla_app my_module
Benefits of App Structure in Horilla CRM
- Convention over configuration — new modules integrate in minutes
- No central urls.py edits for each app
- Automatic permission and feature discovery when registration.py is present
- HTMX-friendly navigation via standardized menu attributes
- Dashboard and workflow hooks without hard-coding in core
Understanding the standard app skeleton is the foundation for every Horilla CRM module. With registration.py, menu.py, signals.py, and dashboard.py in place, your app is visible to the full platform — not just the database.
Part 1 covered How Horilla CRM Apps Work: A Deep Dive into registration.py, menu.py, and AppLauncher; Part 2 covers A Complete Guide to HorillaCoreModel and Multi-Tenant CRM Architecture. More posts are at Horilla Blogs; share feedback on GitHub.