Horilla’s main Settings page is divided into different sections such as Base, Forecast, Opportunity, and others. Each section can be registered separately by the app that owns it.
My Settings works a little differently.
Instead of grouping settings into sections, My Settings provides a simple, personal sidebar where users can manage things related to their own account and preferences. This can include profile settings, passwords, opportunity teams, Google Calendar, meeting integrations, and other user-specific options.
Behind the scenes, My Settings has its own registry, which makes adding a new menu item quite straightforward.
In this post, we’ll look at how horilla/menu/my_settings_menu.py works and how modules such as Opportunities, Calendar, and Meeting use it.
The My Settings Registry
The registry lives in horilla/menu/my_settings_menu.py.
Unlike the main Settings registry, where menu entries can be grouped into sections, My Settings keeps things flat. Each registered class represents one menu item.
my_settings_menu: List[Any] = []
def register(cls: Type[Any]):
"""Decorator to register a settings menu class."""
my_settings_menu.append(cls)
return cls
def get_my_settings_menu(request=None) -> list[dict]:
"""Return registered settings menu items, filtered by conditions and permissions."""
items = []
for cls in my_settings_menu:
obj = cls()
condition = getattr(obj, "condition", True)
if callable(condition):
if not request or not condition(request):
continue
elif not condition:
continue
perm = getattr(obj, "perm", None)
if perm and request:
if not request.user.is_authenticated or not request.user.has_any_perms(
[perm] if isinstance(perm, str) else perm
):
continue
data = {
"title": getattr(obj, "title", None),
"url": getattr(obj, "url", None),
"active_urls": getattr(obj, "active_urls", []),
"icon": getattr(obj, "icon", None),
"order": getattr(obj, "order", 100),
"attrs": getattr(obj, "attrs", {}),
}
items.append(data)
return sorted(items, key=...)
The important thing to notice here is that there is no title with an items list or any grouping structure. Every registered class becomes an individual link in the My Settings sidebar.
Registering a Menu Item
Let’s look at a real example from the Opportunities module.
from horilla.menu import my_settings_menu
from horilla.urls import reverse_lazy
from horilla.utils.translation import gettext_lazy as _
@my_settings_menu.register
class OpportunityTeamSettings:
"""'My Settings' menu entry for Opportunity Teams."""
title = _("Opportunity Team")
url = reverse_lazy("opportunities:opportunity_team_view")
active_urls = [
"opportunities:opportunity_team_view",
"opportunities:opportunity_team_detail_view",
]
order = 5
attrs = {
"hx-boost": "true",
"hx-target": "#my-settings-content",
"hx-push-url": "true",
"hx-select": "#opportunity-team-view",
"hx-select-oob": "#my-settings-sidebar",
}
The @my_settings_menu.register decorator is what adds this class to the registry.
From there, the registry reads the class attributes and turns them into the menu item displayed to the user.
title
This is the text displayed in the sidebar.
title = _(“Opportunity Team”)
Using gettext_lazy allows the menu label to participate in Horilla CRM‘s translation system.
url
This is the page that opens when the user clicks the menu item.
url = reverse_lazy(“opportunities:opportunity_team_view”)
Horilla uses reverse_lazy here because the menu module can be imported while Django is still starting up and the URL configuration may not have been fully resolved yet.
active_urls
This controls when the menu item should appear as active.
active_urls = [
“opportunities:opportunity_team_view”,
“opportunities:opportunity_team_detail_view”,
]
You can provide a single URL name or multiple URL names.
In this example, both the opportunity team list page and its detail page should keep Opportunity Team highlighted.
order
The order value controls where the item appears in the sidebar.
order = 5
Horilla uses the same ordering approach as the main Settings registry: non-negative values are sorted first, unset values come next, and negative values are handled afterward.
attrs
This is where things become especially useful for HTMX-based pages.
attrs = {
"hx-boost": "true",
"hx-target": "#my-settings-content",
"hx-push-url": "true",
"hx-select": "#opportunity-team-view",
"hx-select-oob": "#my-settings-sidebar",
}
These attributes are added directly to the sidebar <a> element.
So if your menu item needs HTMX behavior, this is where you define it.
One detail worth remembering is that these attributes belong inside the attrs dictionary. They aren’t top-level menu properties.
Adding Permission Checks
Not every My Settings entry needs to be visible to every user.
For that, the registry supports the perm attribute.
For example:
perm = “opportunities.view_opportunity”
The registry checks the permission before adding the item to the menu.
It can also accept multiple permissions:
perm = [
“opportunities.view_opportunity”,
“opportunities.change_opportunity”,
]
The check uses OR semantics, so the user only needs one of the listed permissions.
This makes perm useful when the menu item should depend on what the current user is allowed to access.
If no perm is defined, the registry simply treats it as having no additional permission requirement.
Using Conditions for Feature Availability
Permissions aren’t always enough.
Sometimes a menu item should only appear when a particular feature or integration is actually available.
That’s where condition comes in.
The Meeting integration provides a good example:
@my_settings_menu.register
class MeetingUserSettings:
"""Registers Meeting integration in the My Settings sidebar."""
title = _("Meeting")
url = reverse_lazy("meeting:meeting_user_settings")
active_urls = "meeting:meeting_user_settings"
order = 6
attrs = {
"hx-boost": "true",
"hx-target": "#my-settings-content",
"hx-push-url": "true",
"hx-select": "#meeting-user-settings-view",
"hx-select-oob": "#my-settings-sidebar",
}
condition = staticmethod(
MeetingIntegrationSetting.user_has_menu_access
)
Here, the condition is checked whenever the My Settings menu is built.
If the Meeting integration isn’t configured or isn’t available for the current user, the menu entry isn’t shown.
The Calendar integration follows the same idea, using a condition based on whether Google Calendar integration is enabled.
Using staticmethod allows the registry to call the condition directly:
condition(request)
without needing to pass the class instance.
Permission vs Condition
Although they may look similar, perm and condition solve two different problems.
Think of it this way:
- perm asks: “Is this user allowed to access this?”
- condition asks: “Should this feature be available right now?”
They are independent checks.
If both are defined, the menu item has to pass both before it appears.
This separation is useful for integrations. A user might have permission to use a feature, but if the required integration hasn’t been configured, showing the menu item would not make much sense.
Personal Settings and Integrations Use the Same System
One of the nice things about this design is that Horilla doesn’t create separate menu mechanisms for different types of user settings.
An opportunity team setting can be registered exactly like a Google Calendar setting or a Meeting integration setting.
For example:
- Opportunity Team → personal opportunity configuration
- Google Calendar → calendar integration settings
- Meeting → meeting integration settings
They all use the same registry.
That keeps the architecture simple: if a setting belongs to the current user, it can become a My Settings menu item without needing a completely new menu system.
Don’t Forget auto_import_modules
There is one important step that is easy to miss.
Registering the class isn’t enough. Horilla needs to actually import the module containing the registration.
Your app’s AppConfig should include “menu” in auto_import_modules:
class YourAppConfig(AppLauncher):
auto_import_modules = [“registration”, “signals”, “menu”]
During application startup, AppLauncher.ready() imports <app>.menu.
That import executes the decorator:
@my_settings_menu.register
and adds the class to the registry.
If “menu” is missing, nothing necessarily crashes. The registration code simply never runs, so the menu item quietly doesn’t appear.
This is one of those cases where everything in the menu class can look correct, but the item still won’t show because the module was never imported.
How the Menu Reaches the Template
Once the menu classes have been registered, Horilla makes them available to templates through menu_context_processor in horilla/context_processors.py.
The context processor includes:
return {
...
"my_settings_menu": get_my_settings_menu(request),
...
}
This means the template doesn’t need to know anything about the registry itself. It simply receives the already-processed menu items.
The actual My Settings template, settings/my_settings.html, then loops through them:
<ul>
{% for item in my_settings_menu %}
<li class="py-1.5">
<a
role="button"
href="{{ item.url }}?{{ request.GET.urlencode }}"
{% for attr, value in item.attrs.items %}
{{ attr }}="{{ value }}"
{% endfor %}
class="... {% is_active item.active_urls %}"
>
{% trans item.title %}
</a>
</li>
{% endfor %}
</ul>
Notice how attrs is handled separately:
{% for attr, value in item.attrs.items %}
{{ attr }}=”{{ value }}”
{% endfor %}
That’s why custom HTML and HTMX attributes need to be placed inside:
attrs = {…}
rather than directly alongside title, url, and order.
Adding Your Own My Settings Item
Once you understand the pattern, adding your own entry is quite small.
For example:
from horilla.menu import my_settings_menu
from horilla.urls import reverse_lazy
from horilla.utils.translation import gettext_lazy as _
@my_settings_menu.register
class YourUserSettings:
title = _("My Feature")
url = reverse_lazy("your_app:my_settings")
active_urls = [
"your_app:my_settings",
]
order = 10
attrs = {
"hx-boost": "true",
"hx-target": "#my-settings-content",
"hx-push-url": "true",
}
If the item needs permission control, add perm.
If it should only appear when a feature is enabled, add condition.
Finally, make sure your app loads its menu.py through auto_import_modules.
That’s all that’s required for the registry to pick it up.
The My Settings menu is a good example of how Horilla keeps its menu architecture reusable without making it complicated.
Instead of hardcoding every personal setting into the template, Horilla lets each module register its own entry. The registry takes care of ordering, permissions, conditions, and menu data, while the template only has to render the resulting list.
The most important things to remember are that My Settings uses a flat registry, perm is for access control, condition is for feature availability, and HTMX attributes belong inside the attrs dictionary.
And if your new item mysteriously doesn’t appear, check auto_import_modules first. In many cases, the registration code is perfectly fine — the module simply hasn’t been imported.