When building a modular application like Horilla, adding a new settings page should not require changes to a shared template every time a new module is introduced.
Imagine having Automations, Mail, Core, Process Builder, and several other modules. If every module had to manually add its settings link to the same sidebar template, that template would quickly become difficult to maintain. Every new feature would mean another template change, and over time, those changes could easily become a source of merge conflicts.
Horilla takes a cleaner approach.
Instead of modifying the Settings template, each module registers its own settings menu through a small Python menu.py file. Horilla collects these registrations and builds the sidebar automatically.
This is handled through the settings_menu registry.
Why Horilla Uses a Registry
A registry provides a simple way for different parts of the application to contribute functionality without directly modifying shared code.
For settings menus, each module can define its own menu class and register it with:
@settings_menu.register
The module does not need to know how the Settings sidebar is rendered. It only needs to describe what should appear there.
This keeps the responsibilities separate:
- The module defines its settings entries.
- The registry collects them.
- The Settings view processes and filters them.
- The template renders the final result.
As a result, adding a new settings section becomes a small, isolated change inside the module itself.
Creating a Settings Menu
A typical module can create a menu.py file and register a class like this:
from horilla.urls import reverse_lazy
from horilla.utils.translation import gettext_lazy as _
from horilla.menu import settings_menu
@settings_menu.register
class AutomationSettings:
"""Settings menu entries for the automation module."""
title = _("Automations")
icon = "/assets/icons/automation.svg"
order = 4
items = [
{
"label": _("Mail & Notifications"),
"url": reverse_lazy("automations:automation_view"),
"hx-target": "#settings-content",
"hx-push-url": "true",
"hx-select": "#automation-view",
"hx-select-oob": "#settings-sidebar",
"perm": "automations.view_horillaautomation",
"order": 1,
},
]
There is no special base class that the menu class needs to inherit from. The registry reads the attributes it needs from the class.
The title, icon, and order describe the settings group, while items contains the links that should appear inside that group.
Defining Menu Items
Each item is represented by a dictionary.
The two most important properties are label and url.
label defines the text displayed in the sidebar, while url points to the view that should be loaded.
For example:
{
"label": _("Mail & Notifications"),
"url": reverse_lazy("automations:automation_view"),
}
Horilla also allows additional properties to be included in the dictionary.
This becomes particularly useful with HTMX. Properties such as hx-target, hx-select, hx-push-url, and hx-select-oob can be added directly to the item.
{
"label": _("Mail & Notifications"),
"url": reverse_lazy("automations:automation_view"),
"hx-target": "#settings-content",
"hx-push-url": "true",
"hx-select": "#automation-view",
"hx-select-oob": "#settings-sidebar",
}
The menu system does not need to know about every possible HTML attribute. Additional keys can simply be passed through to the rendered link.
This makes the registry flexible enough to support different navigation behaviours without requiring changes to the central template.
Adding Permissions
Settings entries often need to be visible only to users who have the required permission.
That can be done with the perm property:
“perm”: “automations.view_horillaautomation”,
Horilla checks permissions when building the menu and again when rendering individual items.
At the menu level, the registry collects the permissions used by its items. If the user does not have any of those permissions, the entire group can be excluded.
At the template level, each individual item is checked again before it is displayed.
This means a group can remain visible even when some of its individual entries are hidden.
One important detail is that permission names need to be correct. A typo does not necessarily produce an obvious error. Instead, the permission check can simply return False, causing the menu item or even the entire group to disappear.
For example, this is incorrect if the Django app label is automations:
“perm”: “automation.view_horillaautomation”
It should be:
“perm”: “automations.view_horillaautomation”
The permission used by the menu should also match the permission expected by the view itself. Keeping those two declarations synchronized avoids confusing situations where the menu is visible but the target view denies access.
Controlling the Order
Both settings groups and individual menu items can define an order.
For example:
order = 4
or:
“order”: 1
The ordering system makes it possible for modules to control where their entries appear without requiring changes to a central list.
The registry handles ordering in three stages:
- Entries with a non-negative order are displayed first, from lowest to highest.
- Entries without an order follow in registration order.
- Entries with a negative order are placed at the end, again sorted by their value.
This is useful for modules that need to keep an entry near the bottom of the Settings sidebar. For example, Core can use a negative order to keep its Data Management settings in a predictable position even when other modules are added later.
Sharing a Settings Group Between Modules
The registry approach becomes even more useful when multiple modules need to contribute to the same settings section.
Process Builder is a good example.
The main Process module can register the group:
@settings_menu.register
class ProcessSettings:
title = _("Process Builder")
icon = "/assets/icons/process-management.svg"
order = 5
items = []
Approvals can then add its entries to that group:
from horilla.contrib.process import ProcessSettings
process = ProcessSettings()
process.items.extend([
{
"label": _("Approval Processes"),
"url": reverse_lazy("approvals:approval_process_view"),
"perm": "approvals.view_approvalrule",
"order": 2,
},
])
Reviews can use the same approach to contribute its own entries.
The important part here is the use of extend().
items is initially a class-level list. Because the instance does not define its own items attribute, calling:
process.items.extend(…)
mutates the existing list.
That allows multiple modules to contribute to the same group.
Replacing the list instead would behave differently:
process.items = […]
This creates an instance-level attribute and can prevent other modules from contributing to the shared class-level list.
So when extending a shared settings group, mutating the existing list is intentional.
How the Menu Gets Loaded
You might wonder how Horilla knows that a module contains a menu.py file.
There is no central file containing imports such as:
from automations import menu
from process import menu
from core import menu
Instead, Horilla uses automatic module imports.
Each application can specify modules that should automatically be imported when the application is initialized:
class AutomationsConfig(AppLauncher):
name = "horilla.contrib.automations"
label = "automations"
auto_import_modules = ["registration", "menu", "signals"]
The application launcher then imports those modules:
def _auto_import_modules(self):
for module in self.auto_import_modules:
try:
importlib.import_module(f"{self.name}.{module}")
except ModuleNotFoundError:
logging.warning(...)
When menu is imported, the @settings_menu.register decorator runs.
By the time the application starts serving requests, the registry has already collected the settings menus from the modules that provide them.
A Small Change With a Bigger Benefit
The real advantage of this design is not just that it saves a few lines of template code.
It keeps modules independent.
A developer adding a new settings feature does not need to search for a shared sidebar template, figure out where the new link belongs, and modify unrelated code. The module can describe its own settings entry in its own menu.py.
That makes the feature easier to maintain, review, and remove later.
It also makes Horilla’s Settings sidebar extensible. New modules can participate in the same navigation system simply by registering themselves.
Horilla’s settings menu system is a good example of how a small registry can simplify a larger application.
Instead of hard-coding every settings link into a shared template, each module registers its own menu definition. Permissions control visibility, ordering keeps the sidebar organized, and HTMX attributes allow the links to work with Horilla’s dynamic navigation without requiring module-specific template changes.
The approach also makes it possible for multiple modules to contribute to a single settings group, as seen with Process Builder.
For developers working on Horilla, the pattern is straightforward: create a menu.py, define the settings class, register it with @settings_menu.register, add the required items and permissions, and let the registry handle the rest.
That separation is what keeps the Settings sidebar modular as Horilla continues to grow.