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

StyleWhen to use
features=[“import_data”, “global_search”]Only specific platform capabilities
all=TrueOpt into every registered feature, with optional exclude=[…]
register_feature(…) in your appDefine 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

RegistryPurpose
main_section_menuTop-level sidebar sections (Sales, Marketing, …)
sub_section_menuItems under a main section
floating_menuQuick-create FAB entries
settings_menuModule settings in the Settings area
my_settings_menuPer-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.

Share this article