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
- Assign partners.* permissions to your role
- Open Partners in sidebar
- Create a partner via FAB
- Confirm company isolation (switch company — list should filter)
- 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
- Previous: Part 7 — A Complete Guide to CRM Automations, Approvals, and Workflows in Horilla CRM
- Next: Part 9 — A Complete Guide to REST APIs in Horilla CRM Using Django REST Framework
More posts are at Horilla Blogs; share feedback on GitHub.Or Go to Horilla Demo