惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

U
Unit 42
Vercel News
Vercel News
博客园 - 叶小钗
大猫的无限游戏
大猫的无限游戏
MyScale Blog
MyScale Blog
P
Proofpoint News Feed
量子位
Engineering at Meta
Engineering at Meta
B
Blog RSS Feed
博客园 - 【当耐特】
Recent Announcements
Recent Announcements
Google DeepMind News
Google DeepMind News
D
DataBreaches.Net
Stack Overflow Blog
Stack Overflow Blog
博客园 - 聂微东
小众软件
小众软件
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
IT之家
IT之家
T
The Blog of Author Tim Ferriss
Last Week in AI
Last Week in AI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Jina AI
Jina AI
博客园 - 三生石上(FineUI控件)

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Inside a Horilla CRM App: registration.py, menu.py, and W...
Horilla · 2026-05-27 · via DEV Community

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.