This capstone walks through Python manage.py start_horilla_app and wires a complete Partners module — model, list/detail views, registration, menu, and permissions — end to end.

This post is Part 8 of 28 in the Horilla CRM Technical Blog series.

What is building your first Horilla CRM module?

Building your first Horilla CRM module enables you to:

  • Scaffold with start_horilla_app
  • AppLauncher config in apps.py
  • HorillaCoreModel + registration + menu
  • HorillaListView and HorillaDetailView
  • Company-scoped testing checklist

What we’ll build

  • Partner model (company-scoped, with owner)
  • List + detail + create/edit views
  • Sidebar menu entry
  • Feature registration (search, import/export)
  • API stub (optional)

Step 1: Scaffold the app

cd horilla-crm
python manage.py start_horilla_app partners

This creates:

partners/
├── apps.py
├── registration.py
├── menu.py
├── signals.py
├── filters.py
├── forms.py
├── urls.py
├── models.py
├── views.py
├── api/
├── templates/
└── static/partners/assets/{icons,js}/

Add to INSTALLED_APPS in local_settings.py:

INSTALLED_APPS += ["partners"]

Step 2: AppLauncher config

# partners/apps.py
from horilla.apps import AppLauncher
from horilla.utils.translation import gettext_lazy as _
 
 
class PartnersConfig(AppLauncher):
    default = True
    name = "partners"
    verbose_name = _("Partners")
 
    url_prefix = "crm/partners/"
    url_module = "partners.urls"
    url_namespace = "partners"
 
    auto_import_modules = [
        "registration",
        "signals",
        "menu",
    ]
 
    def get_api_paths(self):
        return [
            {
                "pattern": "crm/partners/",
                "view_or_include": "partners.api.urls",
                "name": "partners_api",
                "namespace": "partners_api",
            }
        ]

Restart the dev server — URLs mount automatically (Part 1).

Step 3: Model

# partners/models.py
from django.conf import settings
from horilla.db import models
from horilla.contrib.core.models import HorillaCoreModel
from horilla.utils.translation import gettext_lazy as _
from horilla.urls import reverse_lazy
 
 
class Partner(HorillaCoreModel):
    name = models.CharField(max_length=255, verbose_name=_("Partner Name"))
    email = models.EmailField(blank=True, verbose_name=_("Email"))
    partner_owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name="partners",
        verbose_name=_("Owner"),
    )
    tier = models.CharField(
        max_length=20,
        choices=[
            ("gold", _("Gold")),
            ("silver", _("Silver")),
            ("bronze", _("Bronze")),
        ],
        default="silver",
    )
 
    OWNER_FIELDS = ["partner_owner"]
 
    class Meta:
        verbose_name = _("Partner")
        verbose_name_plural = _("Partners")
 
    def __str__(self):
        return self.name
 
    def get_detail_url(self):
        return reverse_lazy("partners:partner_detail", kwargs={"pk": self.pk})
python manage.py makemigrations partners
python manage.py migrate

Step 4: Registration (do not skip)

# partners/registration.py
from horilla.registry.feature import register_model_for_feature
 
register_model_for_feature(
    app_label="partners",
    model_name="Partner",
    features=["import_data", "export_data", "global_search"],
)

Run permission sync if your project provides it, or create permissions via admin after migration.

Step 5: Menu

# partners/menu.py
from horilla.urls import reverse_lazy
from horilla.menu import sub_section_menu, floating_menu, MAIN_CONTENT_HX_ATTRS
from horilla.utils.translation import gettext_lazy as _
 
 
@sub_section_menu.register
class PartnersSubMenu:
    section = _("Sales")  # match your main_section_menu key
    title = _("Partners")
    url = reverse_lazy("partners:partners_view")
    icon = "/assets/icons/partners.svg"
    order = 50
    items = {**MAIN_CONTENT_HX_ATTRS, "perm": "partners.view_partner"}
 
 
@floating_menu.register
class PartnerFloating:
    title = _("Partner")
    url = reverse_lazy("partners:partner_create")
    icon = "/assets/icons/partners.svg"
    items = {
        "hx-target": "#modalBox",
        "hx-swap": "innerHTML",
        "onclick": "openModal()",
        "perm": ["partners.add_partner"],
    }

Adjust section to match an existing main_section_menu entry in your deployment.

Step 6: Filter + form

# partners/filters.py
from horilla.contrib.generics.filters import HorillaFilterSet
from .models import Partner
 
 
class PartnerFilter(HorillaFilterSet):
    class Meta:
        model = Partner
        fields = ["name", "email", "tier", "partner_owner"]
# partners/forms.py
from horilla.contrib.generics.forms import HorillaModelForm
from .models import Partner
 
 
class PartnerForm(HorillaModelForm):
    class Meta:
        model = Partner
        fields = ["name", "email", "partner_owner", "tier"]

Step 7: Views

# partners/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.decorators import method_decorator
from horilla.urls import reverse_lazy
from horilla.contrib.generics.views import (
    HorillaView,
    HorillaNavView,
    HorillaListView,
    HorillaDetailView,
    HorillaSingleFormView,
)
from horilla.utils.decorators import htmx_required, permission_required_or_denied
from .models import Partner
from .filters import PartnerFilter
from .forms import PartnerForm
 
 
class PartnersView(LoginRequiredMixin, HorillaView):
    template_name = "partners/partners_view.html"
    nav_url = reverse_lazy("partners:partners_nav")
 
 
class PartnersNav(LoginRequiredMixin, HorillaNavView):
    nav_title = Partner._meta.verbose_name_plural
    list_url = reverse_lazy("partners:partner_list")
    main_url = reverse_lazy("partners:partners_view")
 
 
@method_decorator(htmx_required, name="dispatch")
@method_decorator(
    permission_required_or_denied(["partners.view_partner", "partners.view_own_partner"]),
    name="dispatch",
)
class PartnerListView(LoginRequiredMixin, HorillaListView):
    model = Partner
    view_id = "partners-list"
    filterset_class = PartnerFilter
    search_url = reverse_lazy("partners:partner_list")
    main_url = reverse_lazy("partners:partners_view")
    columns = ["name", "email", "tier", "partner_owner"]
 
 
class PartnerDetailView(LoginRequiredMixin, HorillaDetailView):
    model = Partner
 
 
class PartnerFormView(LoginRequiredMixin, HorillaSingleFormView):
    model = Partner
    form_class = PartnerForm
    success_url = reverse_lazy("partners:partner_list")

Step 8: URLs

# partners/urls.py
from horilla.urls import path
from . import views
 
app_name = "partners"
 
urlpatterns = [
    path("partners-view/", views.PartnersView.as_view(), name="partners_view"),
    path("partners-nav/", views.PartnersNav.as_view(), name="partners_nav"),
    path("partner-list/", views.PartnerListView.as_view(), name="partner_list"),
    path("partner-detail/<int:pk>/", views.PartnerDetailView.as_view(), name="partner_detail"),
    path("partner-create/", views.PartnerFormView.as_view(), name="partner_create"),
    path("partner-edit/<int:pk>/", views.PartnerFormView.as_view(), name="partner_edit"),
]

Step 9: Minimal templates

Create partners/templates/partners/partners_view.html extending your project base (copy structure from horilla_crm/leads/templates/ — shell with #mainContent and HX-load of nav + list).

At minimum:

{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div id="mainContent"
     hx-get="{% url 'partners:partner_list' %}"
     hx-trigger="load"
     hx-swap="outerHTML">
</div>
{% endblock %}

Step 10: Verify

python manage.py check
python manage.py runserver
  1. Assign partners.* permissions to your role
  2. Open Partners in sidebar
  3. Create a partner via FAB
  4. Confirm company isolation (switch company — list should filter)
  5. Test view_own_partner with a non-owner user

Where to go from here

The 28-part series continues with API, real-time, dashboards, CRM modules, and production deployment. See README.md for the full calendar.

  • Add all=True registration for approvals and automations
  • Implement DRF serializers in partners/api/
  • Add dashboard charts in dashboard.py
  • Extend Lead with a Partner FK via normal FK or _inherit_model (Part 6)
  • Contribute docs/PRs back to Git Hub

Benefits of Build Module in Horilla CRM

  • Single tutorial tying Parts 1–7 together
  • Repeatable pattern for any new business entity
  • Production-ready folder layout from day one
  • Clear permission and tenancy validation steps

After this capstone, you have a blueprint for any CRM module. Extend with API, dashboards, and automations using the same conventions.

Continue the series

More posts are at Horilla Blogs; share feedback on GitHub.Or Go to Horilla Demo

Share this article